From 8af752a1364b833cfcbea0048ec623624641caf4 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Wed, 8 Jul 2026 00:01:06 -0400 Subject: [PATCH] Edit org modals --- internal/core/endpoints_org_test.go | 118 +++++++++++++++ internal/core/orgs.go | 118 +++++++++++++-- internal/core/templates/org.html | 134 ++++++------------ internal/core/templates/org_edit_links.html | 65 +++++++++ internal/core/templates/org_edit_profile.html | 42 ++++++ internal/core/web.go | 2 + internal/e2e/org_edit_test.go | 134 ++++++++++++++++++ internal/web/static/link-editor.js | 17 ++- 8 files changed, 525 insertions(+), 105 deletions(-) create mode 100644 internal/core/templates/org_edit_links.html create mode 100644 internal/core/templates/org_edit_profile.html create mode 100644 internal/e2e/org_edit_test.go diff --git a/internal/core/endpoints_org_test.go b/internal/core/endpoints_org_test.go index f296b80..7abb43d 100644 --- a/internal/core/endpoints_org_test.go +++ b/internal/core/endpoints_org_test.go @@ -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. diff --git a/internal/core/orgs.go b/internal/core/orgs.go index 63f1db9..a2329d7 100644 --- a/internal/core/orgs.go +++ b/internal/core/orgs.go @@ -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. diff --git a/internal/core/templates/org.html b/internal/core/templates/org.html index 9f6d9df..2fde169 100644 --- a/internal/core/templates/org.html +++ b/internal/core/templates/org.html @@ -54,9 +54,17 @@
- {{if not .IsAdmin}}
-

About

+
+

About

+ {{if .IsAdmin}} +
+ +
+ {{end}} +
{{if .Org.Description}}
{{markdown .Org.Description}}
@@ -65,9 +73,20 @@ {{end}}
- {{if .Links}} + + {{if or .Links .IsAdmin}}
-

Links

+
+

Links

+ {{if .IsAdmin}} +
+ +
+ {{end}} +
+ {{if .Links}}
{{range .Links}} {{if .Href}} @@ -84,96 +103,13 @@ {{end}} {{end}}
+ {{else}} +

No links yet.

+ {{end}}
{{end}} - {{end}} {{if .IsAdmin}} -
-

Organization profile (public)

-
-
-
- - -
-
- -
- /orgs/ - -
- Lowercase letters, numbers, and hyphens. Changing this changes the organization's URL. -
-
- - -
-
- - - Markdown supported — headings, lists, links, and emphasis. -
- -
-
-
- -
-

Links (public)

-
-

Social media and other sites for your organization. These appear on your public page.

-
-
- {{range .Links}} - - {{end}} -
-
- - -
- -
- - -
-
- {{/* 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 @@
+{{if .IsAdmin}} + + + + + + +{{end}} + {{template "icon-arrow-left" "me-1"}}All organizations {{end}} diff --git a/internal/core/templates/org_edit_links.html b/internal/core/templates/org_edit_links.html new file mode 100644 index 0000000..7d9a8f4 --- /dev/null +++ b/internal/core/templates/org_edit_links.html @@ -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"}} + + + +{{end}} diff --git a/internal/core/templates/org_edit_profile.html b/internal/core/templates/org_edit_profile.html new file mode 100644 index 0000000..cfb9245 --- /dev/null +++ b/internal/core/templates/org_edit_profile.html @@ -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"}} + + + +{{end}} diff --git a/internal/core/web.go b/internal/core/web.go index eb9c00e..8320a4b 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -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) diff --git a/internal/e2e/org_edit_test.go b/internal/e2e/org_edit_test.go new file mode 100644 index 0000000..c00f67f --- /dev/null +++ b/internal/e2e/org_edit_test.go @@ -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) +} diff --git a/internal/web/static/link-editor.js b/internal/web/static/link-editor.js index f082dfc..4e2b58a 100644 --- a/internal/web/static/link-editor.js +++ b/internal/web/static/link-editor.js @@ -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); + }); })();