Edit org modals

This commit is contained in:
Jonathon Leight committed 2026-07-08 00:01:06 -04:00
1 parent 16f1feb360
commit 8af752a136
8 files changed
+525 -105

No files matched your search

+118
View File
@@ -2,6 +2,7 @@ package core
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
@@ -130,6 +131,123 @@ func TestOrgJoinLeavePosts(t *testing.T) {
}
}
// postHX posts a form with the HX-Request header, as an htmx modal submit does.
func postHX(t *testing.T, ts *httptest.Server, host, path string, form url.Values, cookies ...*http.Cookie) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(form.Encode()))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Host = host
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
for _, c := range cookies {
req.AddCookie(c)
}
resp, err := noRedirect().Do(req)
if err != nil {
t.Fatalf("post %s%s: %v", host, path, err)
}
return resp
}
// TestOrgEditModalValidationKeepsModal: a validation error on an htmx modal submit
// re-renders the modal in place (200) with the error and the entered values kept —
// never a redirect that drops the modal and the work. Success sends HX-Redirect.
func TestOrgEditModalValidationKeepsModal(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "orgvalidadmin")
org, err := st.CreateOrg(ctx, "Valid Org", owner.ID)
if err != nil {
t.Fatal(err)
}
base := "/orgs/" + org.Slug
// Invalid profile (empty name): re-rendered modal, entered slug preserved.
bad := postHX(t, ts, h.app, base+"/edit",
url.Values{"name": {""}, "slug": {"keep-this-slug"}, "region": {"R"}, "description": {"D"}}, sess)
body := readBody(t, bad)
if bad.StatusCode != http.StatusOK {
t.Fatalf("invalid profile htmx POST = %d, want 200 (re-rendered modal)", bad.StatusCode)
}
if !strings.Contains(body, "Enter an organization name.") {
t.Fatalf("re-rendered profile modal missing the error:\n%s", body)
}
if !strings.Contains(body, `value="keep-this-slug"`) || !strings.Contains(body, `form="org-profile-form"`) {
t.Fatalf("re-rendered profile modal didn't preserve entered values:\n%s", body)
}
// Valid profile: HX-Redirect to the new canonical URL (closes the modal).
good := postHX(t, ts, h.app, base+"/edit",
url.Values{"name": {"Renamed"}, "slug": {"renamed-valid"}, "region": {""}, "description": {""}}, sess)
good.Body.Close()
if good.StatusCode != http.StatusOK || good.Header.Get("HX-Redirect") != "/orgs/renamed-valid" {
t.Fatalf("valid profile htmx POST = %d HX-Redirect=%q, want 200 → /orgs/renamed-valid", good.StatusCode, good.Header.Get("HX-Redirect"))
}
// Invalid links (bad URL): re-rendered editor with the bad value kept.
badLinks := postHX(t, ts, h.app, "/orgs/renamed-valid/links",
url.Values{"link_platform": {"website"}, "link_label": {""}, "link_url": {"not a url"}}, sess)
lb := readBody(t, badLinks)
if badLinks.StatusCode != http.StatusOK || !strings.Contains(lb, "data-link-editor") || !strings.Contains(lb, "not a url") {
t.Fatalf("re-rendered links modal didn't preserve the bad entry:\n%s", lb)
}
}
// TestOrgEditModals: the Home tab shows read-only profile/links for everyone, with
// admin-only "Edit" buttons that open modal fragments (admin-gated GET endpoints).
func TestOrgEditModals(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "orgeditadmin") // creator = admin
org, err := st.CreateOrg(ctx, "Edit Org", owner.ID)
if err != nil {
t.Fatal(err)
}
// Admin Home: read-only About + the two Edit buttons.
home := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug, sess))
if !strings.Contains(home, `data-testid="edit-profile"`) || !strings.Contains(home, `data-testid="edit-links"`) {
t.Fatal("admin org Home missing the Edit buttons")
}
if strings.Contains(home, `action="/orgs/`+org.Slug+`/edit"`) {
t.Fatal("admin org Home should not render the profile form inline (it's in a modal now)")
}
// Profile modal fragment: the form, no page chrome.
prof := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/edit", sess))
if !strings.Contains(prof, `form="org-profile-form"`) || !strings.Contains(prof, `name="name"`) || !strings.Contains(prof, `name="slug"`) {
t.Fatalf("profile modal fragment missing expected fields:\n%s", prof)
}
if strings.Contains(prof, "back-link") {
t.Fatal("profile modal fragment should be modal chrome, not a full page")
}
// Links modal fragment: the link editor.
links := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/links", sess))
if !strings.Contains(links, "data-link-editor") || !strings.Contains(links, `form="org-links-form"`) {
t.Fatalf("links modal fragment missing the editor:\n%s", links)
}
// A plain member: read-only Home, no Edit buttons, and the modal GETs 404.
member, msess := appLogin(t, ts, st, ctx, h.app, "orgeditmember")
if err := st.AddOrgMember(ctx, org.ID, member.ID, "member"); err != nil {
t.Fatal(err)
}
mhome := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug, msess))
if strings.Contains(mhome, `data-testid="edit-profile"`) || strings.Contains(mhome, `data-testid="edit-links"`) {
t.Fatal("non-admin member should not see the Edit buttons")
}
for _, p := range []string{"/edit", "/links"} {
resp := do(t, ts, h.app, "/orgs/"+org.Slug+p, msess)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("non-admin GET %s = %d, want 404", p, resp.StatusCode)
}
}
}
// 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.
+104 -14
View File
@@ -128,7 +128,6 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
"Members": members,
"Repeaters": repeaters,
"Links": links,
"Platforms": store.LinkPlatforms(),
"PlatformsJS": web.LinkPlatformsJS(store.LinkPlatforms()),
"HasMap": mapped > 0,
"MemberCount": len(members),
@@ -265,6 +264,67 @@ func (s *Handlers) pageOrgRepeaters(w http.ResponseWriter, r *http.Request) {
}
// handleEditOrg updates an org's slug, name, description, and region (admin only).
// pageEditOrg renders the org profile edit modal fragment (admin only), loaded via
// htmx into the org Home page.
func (s *Handlers) pageEditOrg(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
org, err := s.Store.GetOrg(r.Context(), id)
if err != nil {
s.NotFound(w, r)
return
}
s.renderProfileModal(w, r, org.Slug, org.Name, org.Slug, org.Region, org.Description, "")
}
// pageEditLinks renders the org links edit modal fragment (admin only), loaded via
// htmx into the org Home page.
func (s *Handlers) pageEditLinks(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
links, err := s.Store.ListOrgLinks(r.Context(), id)
if err != nil {
s.ServerError(w, r, "could not load links", err)
return
}
s.renderLinksModal(w, r, orgParam(r), links, "")
}
// renderProfileModal renders the profile edit modal fragment with the given field
// values and optional error — reused by the GET (prefilled from the org) and the
// POST error re-render (repopulated from the submission, so no work is lost).
func (s *Handlers) renderProfileModal(w http.ResponseWriter, r *http.Request, orgSlug, name, slug, region, desc, errMsg string) {
s.Render(w, r, "org_edit_profile.html", map[string]any{
"OrgSlug": orgSlug, "Name": name, "Slug": slug, "Region": region,
"Description": desc, "Error": errMsg, "Layout": "org-profile-modal",
})
}
// renderLinksModal renders the links edit modal fragment with the given rows and
// optional error — reused by the GET and the POST error re-render.
func (s *Handlers) renderLinksModal(w http.ResponseWriter, r *http.Request, orgSlug string, links []store.OrgLink, errMsg string) {
s.Render(w, r, "org_edit_links.html", map[string]any{
"OrgSlug": orgSlug, "Links": links, "Platforms": store.LinkPlatforms(),
"Error": errMsg, "Layout": "org-links-modal",
})
}
// hxRedirect navigates the browser to url: for an htmx request (a modal form) via
// the HX-Redirect header so it does a full navigation that closes the modal;
// otherwise a plain 303 (no-JS / non-htmx fallback).
func (s *Handlers) hxRedirect(w http.ResponseWriter, r *http.Request, url string) {
if r.Header.Get("HX-Request") != "" {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, url, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
@@ -274,25 +334,34 @@ func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) {
slug := strings.ToLower(strings.TrimSpace(r.FormValue("slug")))
desc := strings.TrimSpace(r.FormValue("description"))
region := strings.TrimSpace(r.FormValue("region"))
// fail keeps the modal open with the entered values on an htmx submit; a non-htmx
// (no-JS) submit falls back to the full-page error redirect.
fail := func(msg string) {
if r.Header.Get("HX-Request") != "" {
s.renderProfileModal(w, r, orgParam(r), name, slug, region, desc, msg)
return
}
orgErr(w, r, msg)
}
if name == "" || len(name) > 80 {
orgErr(w, r, "Enter an organization name.")
fail("Enter an organization name.")
return
}
if !store.ValidOrgSlug(slug) {
orgErr(w, r, "Slug must be 3–40 lowercase letters, numbers, and hyphens (and not reserved).")
fail("Slug must be 3–40 lowercase letters, numbers, and hyphens (and not reserved).")
return
}
desc = web.Clip(desc, 2000)
region = web.Clip(region, 120)
if err := s.Store.UpdateOrg(r.Context(), id, slug, name, desc, region); errors.Is(err, store.ErrDuplicate) {
orgErr(w, r, "That URL slug is already taken.")
fail("That URL slug is already taken.")
return
} else if err != nil {
orgErr(w, r, "Could not save changes.")
fail("Could not save changes.")
return
}
// The slug may have changed; redirect to the new canonical URL.
http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
// The slug may have changed; navigate to the new canonical URL (closing the modal).
s.hxRedirect(w, r, "/orgs/"+slug)
}
// handleSetOrgLinks replaces an org's whole set of social/site links from the
@@ -312,6 +381,27 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
platforms := r.Form["link_platform"]
labels := r.Form["link_label"]
urls := r.Form["link_url"]
// entered mirrors the submitted rows verbatim so a validation error can
// re-render the editor with the user's work intact (see fail).
var entered []store.OrgLink
for i, u := range urls {
p := ""
if i < len(platforms) {
p = platforms[i]
}
l := ""
if i < len(labels) {
l = labels[i]
}
entered = append(entered, store.OrgLink{Platform: p, Label: strings.TrimSpace(l), URL: strings.TrimSpace(u)})
}
fail := func(msg string) {
if r.Header.Get("HX-Request") != "" {
s.renderLinksModal(w, r, orgParam(r), entered, msg)
return
}
orgErr(w, r, msg)
}
var links []store.OrgLink
for i, raw := range urls {
u := strings.TrimSpace(raw)
@@ -324,7 +414,7 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
}
p, ok := store.OrgLinkPlatform(platform)
if !ok {
orgErr(w, r, "Choose a type for each link.")
fail("Choose a type for each link.")
return
}
// Validate/canonicalise per kind, leaving `u` as the value to persist.
@@ -333,13 +423,13 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
if store.LooksLikeURL(u) {
u = store.NormalizeLinkURL(u)
if !store.ValidLinkURL(u) {
orgErr(w, r, "Enter a valid username or invite link.")
fail("Enter a valid username or invite link.")
return
}
} else {
v := strings.TrimPrefix(u, "@")
if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") {
orgErr(w, r, "Enter a valid username (no spaces) or an invite link.")
fail("Enter a valid username (no spaces) or an invite link.")
return
}
u = v
@@ -347,14 +437,14 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
case store.KindHandle:
canon, ok := p.CanonicalHandleURL(u)
if !ok {
orgErr(w, r, "Enter a valid "+p.Name+" username or profile URL.")
fail("Enter a valid " + p.Name + " username or profile URL.")
return
}
u = canon
default: // KindURL — accept a bare domain by assuming https:// before validating.
u = store.NormalizeLinkURL(u)
if !store.ValidLinkURL(u) {
orgErr(w, r, "Each link must be a valid http:// or https:// URL.")
fail("Each link must be a valid http:// or https:// URL.")
return
}
}
@@ -370,10 +460,10 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
}
}
if err := s.Store.ReplaceOrgLinks(r.Context(), id, links); err != nil {
orgErr(w, r, "Could not save links.")
fail("Could not save links.")
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
s.hxRedirect(w, r, "/orgs/"+orgParam(r))
}
// requireOrgAdmin resolves {id} and verifies the current user is an org admin.
+44 -90
View File
@@ -54,9 +54,17 @@
<div class="row row-cards mt-1">
<!-- Left column: details -->
<div class="col-lg-8">
{{if not .IsAdmin}}
<div class="card">
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-header">
<h3 class="card-title">About</h3>
{{if .IsAdmin}}
<div class="card-actions">
<button type="button" class="btn btn-sm" data-testid="edit-profile"
data-bs-toggle="modal" data-bs-target="#org-profile-modal"
hx-get="/orgs/{{.Org.Slug}}/edit" hx-target="#org-profile-modal-content" hx-swap="innerHTML">{{template "icon-pencil" "me-1"}}Edit</button>
</div>
{{end}}
</div>
<div class="card-body">
{{if .Org.Description}}
<div class="markdown">{{markdown .Org.Description}}</div>
@@ -65,9 +73,20 @@
{{end}}
</div>
</div>
{{if .Links}}
{{if or .Links .IsAdmin}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Links</h3></div>
<div class="card-header">
<h3 class="card-title">Links</h3>
{{if .IsAdmin}}
<div class="card-actions">
<button type="button" class="btn btn-sm" data-testid="edit-links"
data-bs-toggle="modal" data-bs-target="#org-links-modal"
hx-get="/orgs/{{.Org.Slug}}/links" hx-target="#org-links-modal-content" hx-swap="innerHTML">{{template "icon-pencil" "me-1"}}Edit</button>
</div>
{{end}}
</div>
{{if .Links}}
<div class="list-group list-group-flush">
{{range .Links}}
{{if .Href}}
@@ -84,96 +103,13 @@
{{end}}
{{end}}
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No links yet.</p></div>
{{end}}
</div>
{{end}}
{{end}}
{{if .IsAdmin}}
<div class="card">
<div class="card-header"><h3 class="card-title">Organization profile <span class="text-secondary">(public)</span></h3></div>
<div class="card-body">
<form method="post" action="/orgs/{{.Org.Slug}}/edit">
<div class="mb-3">
<label class="form-label" for="org_name">Name</label>
<input type="text" class="form-control" id="org_name" name="name" value="{{.Org.Name}}" maxlength="80" required>
</div>
<div class="mb-3">
<label class="form-label" for="org_slug">URL slug</label>
<div class="input-group">
<span class="input-group-text">/orgs/</span>
<input type="text" class="form-control font-monospace" id="org_slug" name="slug" value="{{.Org.Slug}}"
pattern="[a-z0-9]+(-[a-z0-9]+)*" minlength="3" maxlength="40" required>
</div>
<small class="form-hint">Lowercase letters, numbers, and hyphens. Changing this changes the organization's URL.</small>
</div>
<div class="mb-3">
<label class="form-label" for="org_region">Region <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" id="org_region" name="region" value="{{.Org.Region}}" maxlength="120" placeholder="e.g. Buffalo, NY">
</div>
<div class="mb-3">
<label class="form-label" for="org_description">Description</label>
<textarea class="form-control" id="org_description" name="description" rows="3" maxlength="2000" placeholder="What this organization is about, where it operates, etc.">{{.Org.Description}}</textarea>
<small class="form-hint">Markdown supported — headings, lists, links, and emphasis.</small>
</div>
<button type="submit" class="btn btn-primary">Save profile</button>
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Links <span class="text-secondary">(public)</span></h3></div>
<div class="card-body">
<p class="text-secondary">Social media and other sites for your organization. These appear on your public page.</p>
<form method="post" action="/orgs/{{.Org.Slug}}/links" data-link-editor>
<div data-link-rows>
{{range .Links}}
<div class="link-row row g-2 mb-2 align-items-center">
<div class="col-12 col-sm-3">
<select class="form-select link-platform" name="link_platform" aria-label="Link type">
{{$sel := .Platform}}
{{range $.Platforms}}<option value="{{.Key}}"{{if eq .Key $sel}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-sm-3 link-label-col">
<input class="form-control link-label" name="link_label" value="{{.Label}}" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
</div>
<div class="col">
<input class="form-control link-value" type="text" name="link_url" value="{{.URL}}" maxlength="300" aria-label="Link value">
</div>
<div class="col-auto">
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
</div>
</div>
{{end}}
</div>
<div class="btn-list mt-2">
<button type="button" class="btn" data-link-add>{{template "icon-plus" "me-1"}}Add link</button>
<button type="submit" class="btn btn-primary">Save links</button>
</div>
<template data-link-tpl>
<div class="link-row row g-2 mb-2 align-items-center">
<div class="col-12 col-sm-3">
<select class="form-select link-platform" name="link_platform" aria-label="Link type">
{{range .Platforms}}<option value="{{.Key}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-sm-3 link-label-col">
<input class="form-control link-label" name="link_label" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
</div>
<div class="col">
<input class="form-control link-value" type="text" name="link_url" maxlength="300" aria-label="Link value">
</div>
<div class="col-auto">
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
</div>
</div>
</template>
</form>
<script nonce="{{.Nonce}}">window.MESHTENDER_LINK_PLATFORMS = {{.PlatformsJS}};</script>
<script src="/static/link-editor.js"></script>
</div>
</div>
{{/* Custom domains — hidden for now; the hosting/TLS infrastructure isn't in
place yet. Re-enable this card (plus the domains data in pageOrg and the
/domains routes in web.go) once it is.
@@ -250,5 +186,23 @@
</div>
</div>
{{if .IsAdmin}}
<!-- Admin edit modals; the About/Links card-header buttons load their fragments here via htmx. -->
<div class="modal modal-blur fade" id="org-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="org-profile-modal-content"></div>
</div>
</div>
<div class="modal modal-blur fade" id="org-links-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="org-links-modal-content"></div>
</div>
</div>
<!-- Per-platform link config for the links editor loaded into the modal above;
link-editor.js re-initializes on htmx:afterSwap. -->
<script nonce="{{.Nonce}}">window.MESHTENDER_LINK_PLATFORMS = {{.PlatformsJS}};</script>
<script src="/static/link-editor.js"></script>
{{end}}
<a class="back-link mt-3" href="/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
{{end}}
@@ -0,0 +1,65 @@
{{/* org-links-modal is the htmx fragment swapped into #org-links-modal. The form
(data-link-editor) is wired up by link-editor.js, which re-initializes on
htmx:afterSwap. It posts via htmx so a server validation error re-renders this
fragment in place (modal stays open, entered rows preserved); on success the
handler sends HX-Redirect to reload the org page. Per-platform config comes
from the window.MESHTENDER_LINK_PLATFORMS global set on the page. */}}
{{define "org-links-modal"}}
<div class="modal-header">
<h5 class="modal-title">Edit links</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<p class="text-secondary">Social media and other sites for your organization. These appear on your public page.</p>
<form method="post" action="/orgs/{{.OrgSlug}}/links" data-link-editor id="org-links-form"
hx-post="/orgs/{{.OrgSlug}}/links" hx-target="#org-links-modal-content" hx-swap="innerHTML">
<div data-link-rows>
{{range .Links}}
<div class="link-row row g-2 mb-2 align-items-center">
<div class="col-12 col-sm-3">
<select class="form-select link-platform" name="link_platform" aria-label="Link type">
{{$sel := .Platform}}
{{range $.Platforms}}<option value="{{.Key}}"{{if eq .Key $sel}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-sm-3 link-label-col">
<input class="form-control link-label" name="link_label" value="{{.Label}}" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
</div>
<div class="col">
<input class="form-control link-value" type="text" name="link_url" value="{{.URL}}" maxlength="300" aria-label="Link value">
</div>
<div class="col-auto">
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
</div>
</div>
{{end}}
</div>
<div class="btn-list mt-2">
<button type="button" class="btn" data-link-add>{{template "icon-plus" "me-1"}}Add link</button>
</div>
<template data-link-tpl>
<div class="link-row row g-2 mb-2 align-items-center">
<div class="col-12 col-sm-3">
<select class="form-select link-platform" name="link_platform" aria-label="Link type">
{{range .Platforms}}<option value="{{.Key}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-sm-3 link-label-col">
<input class="form-control link-label" name="link_label" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
</div>
<div class="col">
<input class="form-control link-value" type="text" name="link_url" maxlength="300" aria-label="Link value">
</div>
<div class="col-auto">
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
</div>
</div>
</template>
</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="org-links-form" class="btn btn-primary">Save links</button>
</div>
{{end}}
@@ -0,0 +1,42 @@
{{/* org-profile-modal is the htmx fragment swapped into #org-profile-modal. It
posts via htmx so a server validation error re-renders this fragment in place
(modal stays open, entered values preserved); on success the handler sends
HX-Redirect to reload the org page. The plain action= is the no-JS fallback. */}}
{{define "org-profile-modal"}}
<div class="modal-header">
<h5 class="modal-title">Edit profile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<form method="post" action="/orgs/{{.OrgSlug}}/edit" id="org-profile-form"
hx-post="/orgs/{{.OrgSlug}}/edit" hx-target="#org-profile-modal-content" hx-swap="innerHTML">
<div class="mb-3">
<label class="form-label" for="org_name">Name</label>
<input type="text" class="form-control" id="org_name" name="name" value="{{.Name}}" maxlength="80" required>
</div>
<div class="mb-3">
<label class="form-label" for="org_slug">URL slug</label>
<div class="input-group">
<span class="input-group-text">/orgs/</span>
<input type="text" class="form-control font-monospace" id="org_slug" name="slug" value="{{.Slug}}"
pattern="[a-z0-9]+(-[a-z0-9]+)*" minlength="3" maxlength="40" required>
</div>
<small class="form-hint">Lowercase letters, numbers, and hyphens. Changing this changes the organization's URL.</small>
</div>
<div class="mb-3">
<label class="form-label" for="org_region">Region <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" id="org_region" name="region" value="{{.Region}}" maxlength="120" placeholder="e.g. Buffalo, NY">
</div>
<div class="mb-0">
<label class="form-label" for="org_description">Description</label>
<textarea class="form-control" id="org_description" name="description" rows="4" maxlength="2000" placeholder="What this organization is about, where it operates, etc.">{{.Description}}</textarea>
<small class="form-hint">Markdown supported — headings, lists, links, and emphasis.</small>
</div>
</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="org-profile-form" class="btn btn-primary">Save profile</button>
</div>
{{end}}
+2
View File
@@ -212,7 +212,9 @@ func (s *Handlers) appRouter() chi.Router {
r.Get("/orgs/new", s.pageNewOrg)
r.Post("/orgs", s.handleCreateOrg)
r.Get("/orgs/{id}/edit", s.pageEditOrg)
r.Post("/orgs/{id}/edit", s.handleEditOrg)
r.Get("/orgs/{id}/links", s.pageEditLinks)
r.Post("/orgs/{id}/links", s.handleSetOrgLinks)
r.Get("/orgs/{id}/join", s.pageJoinOrg)
r.Post("/orgs/{id}/join", s.handleJoinOrg)
+134
View File
@@ -0,0 +1,134 @@
//go:build browser
package e2e
import (
"testing"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
// TestE2EOrgProfileModal: an admin opens the About card's Edit button and the
// profile fragment loads into the modal, prefilled, under the strict CSP.
func TestE2EOrgProfileModal(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2eorgprofile")
org, err := srv.store.CreateOrg(srv.ctx, "Profile Org", user.ID) // creator = admin
if err != nil {
t.Fatalf("create org: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var name string
homeURL := srv.appURL + "/orgs/" + org.Slug
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(homeURL),
chromedp.WaitVisible(`[data-testid="edit-profile"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="edit-profile"]`, chromedp.ByQuery),
chromedp.WaitVisible(`#org-profile-modal-content input[name=name]`, chromedp.ByQuery),
chromedp.Value(`#org-profile-modal-content input[name=name]`, &name, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", homeURL, err)
}
if name != "Profile Org" {
t.Fatalf("profile modal name field = %q, want %q", name, "Profile Org")
}
watch.assertClean(t)
}
// TestE2EOrgProfileModalKeepsOpenOnError: a server validation error (renaming to a
// slug already taken — passes HTML5 checks, rejected server-side) re-renders the
// modal in place instead of navigating away, so the modal stays open with the
// error shown and the entered work preserved.
func TestE2EOrgProfileModalKeepsOpenOnError(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2eorgerr")
// Two orgs owned by the same admin; renaming one onto the other's slug collides.
if _, err := srv.store.CreateOrg(srv.ctx, "Alpha", user.ID); err != nil {
t.Fatalf("create org alpha: %v", err)
}
beta, err := srv.store.CreateOrg(srv.ctx, "Beta", user.ID)
if err != nil {
t.Fatalf("create org beta: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var errShown, modalShown bool
var nameVal string
homeURL := srv.appURL + "/orgs/" + beta.Slug
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(homeURL),
chromedp.WaitVisible(`[data-testid="edit-profile"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="edit-profile"]`, chromedp.ByQuery),
chromedp.WaitVisible(`#org-profile-modal-content input[name=slug]`, chromedp.ByQuery),
// Collide with Alpha's slug, then Save (footer button posts the modal form).
chromedp.SetValue(`#org-profile-modal-content input[name=slug]`, "alpha", chromedp.ByQuery),
chromedp.Click(`#org-profile-modal-content .modal-footer button[type=submit]`, chromedp.ByQuery),
// htmx swaps the error fragment back into the still-open modal.
chromedp.WaitVisible(`#org-profile-modal-content .alert-danger`, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelector('#org-profile-modal').classList.contains('show')`, &modalShown),
chromedp.Evaluate(`!!document.querySelector('#org-profile-modal-content .alert-danger')`, &errShown),
chromedp.Value(`#org-profile-modal-content input[name=name]`, &nameVal, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", homeURL, err)
}
if !modalShown {
t.Fatal("modal closed after a validation error; it should stay open")
}
if !errShown {
t.Fatal("no error shown in the re-rendered modal")
}
if nameVal != "Beta" {
t.Fatalf("entered name not preserved after error: got %q, want %q", nameVal, "Beta")
}
watch.assertClean(t)
}
// TestE2EOrgLinksModalAddRow: the links editor works inside the htmx-loaded modal —
// clicking "Add link" appends a row, proving link-editor.js re-initializes on the
// swapped-in form (not just at page load).
func TestE2EOrgLinksModalAddRow(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2eorglinks")
org, err := srv.store.CreateOrg(srv.ctx, "Links Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
const countRows = `document.querySelectorAll('#org-links-modal-content .link-row').length`
var before, after int
homeURL := srv.appURL + "/orgs/" + org.Slug
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(homeURL),
chromedp.WaitVisible(`[data-testid="edit-links"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="edit-links"]`, chromedp.ByQuery),
chromedp.WaitVisible(`#org-links-modal-content [data-link-add]`, chromedp.ByQuery),
chromedp.Evaluate(countRows, &before),
chromedp.Click(`#org-links-modal-content [data-link-add]`, chromedp.ByQuery),
chromedp.Evaluate(countRows, &after),
); err != nil {
t.Fatalf("browser run against %s: %v", homeURL, err)
}
if after != before+1 {
t.Fatalf("Add link: rows %d -> %d, want +1 (link-editor.js didn't re-init in the modal)", before, after)
}
watch.assertClean(t)
}
+16 -1
View File
@@ -168,5 +168,20 @@
}
}
document.querySelectorAll('[data-link-editor]').forEach(initEditor);
// initAll wires up every [data-link-editor] under root exactly once. Guarded so
// re-running (on htmx swaps) never double-binds an already-initialized editor.
function initAll(root) {
var scope = root && root.querySelectorAll ? root : document;
scope.querySelectorAll('[data-link-editor]').forEach(function (el) {
if (el.dataset.linkEditorReady) return;
el.dataset.linkEditorReady = '1';
initEditor(el);
});
}
initAll(document);
// The org page loads the editor into a modal via htmx; init the swapped-in form.
document.body && document.addEventListener('htmx:afterSwap', function (e) {
initAll(e.target || document);
});
})();