From ba2eb4cb7116c0b9fab9e9f9122b3ded8a8c09f1 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Thu, 30 Jul 2026 10:57:24 -0400 Subject: [PATCH] Show org count --- internal/core/org_count_test.go | 123 +++++++++++++++++++++++++ internal/marketing/orgs.go | 11 +++ internal/marketing/templates/orgs.html | 18 +++- internal/store/orgs.go | 45 ++++++++- 4 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 internal/core/org_count_test.go diff --git a/internal/core/org_count_test.go b/internal/core/org_count_test.go new file mode 100644 index 0000000..d9a4e59 --- /dev/null +++ b/internal/core/org_count_test.go @@ -0,0 +1,123 @@ +package core + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "testing" +) + +var orgCountRe = regexp.MustCompile(`data-testid="org-count"[^>]*>\s*([0-9]+)\s+organizations?`) + +// TestOrgDirectoryStatesItsSize pins audit U5: the directory showed 50 rows with no +// indication whether that was the whole thing or a slice. +// +// The number is a filter-wide total, not a tally of rows on screen — the "Show more" +// control appends pages via htmx without replacing the page header, so a running count +// there would be wrong the moment anyone used it. The trade is that the total can exceed +// the rows visible, which is exactly the information that was missing. +func TestOrgDirectoryStatesItsSize(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + + get := func(path string) string { + t.Helper() + resp := do(t, ts, h.root, path) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s = %d, want 200", path, resp.StatusCode) + } + return string(body) + } + advertised := func(html, where string) int { + t.Helper() + m := orgCountRe.FindStringSubmatch(html) + if m == nil { + t.Fatalf("%s: no organization count rendered", where) + } + n, err := strconv.Atoi(m[1]) + if err != nil { + t.Fatalf("%s: unparseable count %q", where, m[1]) + } + return n + } + + // Empty directory: the count must still render, and say zero rather than nothing. + if got := advertised(get("/orgs"), "empty directory"); got != 0 { + t.Errorf("empty directory advertises %d organizations, want 0", got) + } + + // Seed a known number, one of which is findable by a distinctive search term. + owner, err := st.CreateUser(ctx, "countowner", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + const seeded = 7 + for i := 0; i < seeded; i++ { + name := fmt.Sprintf("Count Org %d", i) + if i == 0 { + name = "Zarquon Ridge Mesh" + } + if _, err := st.CreateOrg(ctx, name, owner.ID); err != nil { + t.Fatalf("create org %d: %v", i, err) + } + } + + html := get("/orgs") + if got := advertised(html, "seeded directory"); got != seeded { + t.Errorf("directory advertises %d organizations, want %d", got, seeded) + } + // The count has to agree with what's actually listed, or it contradicts the rows + // right beneath it. Both come from the same search predicate for this reason. + if rows := strings.Count(html, `data-testid="org-row"`); rows > 0 && rows != seeded { + t.Errorf("advertised %d organizations but rendered %d rows", seeded, rows) + } + + // With a search active the count must describe the matches, not the directory. + filtered := get("/orgs?q=Zarquon") + if got := advertised(filtered, "filtered directory"); got != 1 { + t.Errorf("search for Zarquon advertises %d organizations, want 1", got) + } + if !strings.Contains(filtered, "matching") { + t.Error("filtered count doesn't say it's describing matches") + } + // Singular vs plural, since the count is user-facing copy. + if strings.Contains(filtered, "1 organizations") { + t.Error(`filtered count reads "1 organizations"`) + } + + // The "load more" fragment must NOT carry a count. It's swapped into the list, not + // the header, so a count inside it would either be discarded or — worse, if someone + // later wires it up — overwrite the total with a per-page number. + req, err := http.NewRequest(http.MethodGet, ts.URL+"/orgs", nil) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Host = h.root + req.Header.Set("HX-Request", "true") + resp, err := noRedirect().Do(req) + if err != nil { + t.Fatalf("fragment request: %v", err) + } + frag, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatalf("read fragment: %v", err) + } + if strings.Contains(string(frag), `data-testid="org-count"`) { + t.Error("the htmx fragment carries an organization count; it should only render rows") + } + if strings.Contains(string(frag), "") { + t.Error("the fragment references a data key the fragment path doesn't supply") + } + if !strings.Contains(string(frag), `data-testid="org-row"`) { + t.Error("the fragment rendered no rows — fixture or layout problem") + } +} diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go index a13c5e5..e5356c0 100644 --- a/internal/marketing/orgs.go +++ b/internal/marketing/orgs.go @@ -53,6 +53,17 @@ func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) { // htmx "load more": return just the rows + next control to append in place. if r.Header.Get("HX-Request") != "" { data["Layout"] = "orgs-frag" + } else { + // Only on a full page render: the count lives in the page header, which the + // fragment doesn't replace, so re-querying it for an append would be wasted + // work. It's a filter-wide total rather than a running tally precisely so it + // stays correct as more pages are appended beneath it. + total, err := s.Store.CountPublicOrgs(r.Context(), query) + if err != nil { + s.ServerError(w, r, "could not load organizations", err) + return + } + data["Total"] = total } s.Render(w, r, "orgs.html", data) } diff --git a/internal/marketing/templates/orgs.html b/internal/marketing/templates/orgs.html index 9ab8be6..f246df6 100644 --- a/internal/marketing/templates/orgs.html +++ b/internal/marketing/templates/orgs.html @@ -1,7 +1,19 @@ {{define "title"}}Organizations · MeshTender{{end}} {{define "header"}}
-

Organizations

+
+

Organizations

+ {{/* A filter-wide total, so a visitor can tell whether they're looking at the whole + directory or a slice of it. It counts matches rather than rows on screen, which + keeps it accurate as htmx appends further pages below. */}} +
+ {{if .Query}} + {{.Total}} {{if eq .Total 1}}organization{{else}}organizations{{end}} matching “{{.Query}}” + {{else}} + {{.Total}} {{if eq .Total 1}}organization{{else}}organizations{{end}} + {{end}} +
+
{{if .UserName}}
{{template "icon-plus" "me-1"}}Create organization @@ -14,7 +26,7 @@ htmx "load more" fragment so appended pages match the first. */}} {{define "org-rows"}} {{range .All}} - +
{{.Name}} {{if .Region}}{{.Region}}{{end}} @@ -84,7 +96,5 @@ {{end}}
- -
{{end}} diff --git a/internal/store/orgs.go b/internal/store/orgs.go index cacd549..b2d3afb 100644 --- a/internal/store/orgs.go +++ b/internal/store/orgs.go @@ -240,16 +240,51 @@ func escapeLikePattern(s string) string { // denormalized columns on organizations (trigger-maintained; see migration // 0033), so every ordering sorts and seeks on an indexed column rather than a // correlated count subquery computed per org on each page load. +// orgSearchFilter builds the directory's substring-search predicate over +// name/description/region (trigram-indexed, migration 0033), registering its argument +// through add. Returns "" when no search is active. +// +// Shared by ListPublicOrgsPage and CountPublicOrgs so the two can never disagree about +// what "matching" means — a divergence would show a count that contradicts the rows +// right beneath it. +func orgSearchFilter(query string, add func(any) string) string { + q := strings.TrimSpace(query) + if q == "" { + return "" + } + ph := add("%" + escapeLikePattern(q) + "%") + return fmt.Sprintf("(o.name ILIKE %[1]s OR o.description ILIKE %[1]s OR o.region ILIKE %[1]s)", ph) +} + +// CountPublicOrgs returns how many organizations match the directory's search filter, +// ignoring any keyset position. That's deliberate: the directory loads further pages by +// appending rows via htmx, so a "shown so far" number rendered outside the swapped +// fragment would go stale on the first "Show more". A filter-wide total stays correct +// however many pages are on screen. +func (s *Store) CountPublicOrgs(ctx context.Context, query string) (int, error) { + var args []any + add := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) } + + where := "" + if f := orgSearchFilter(query, add); f != "" { + where = "WHERE " + f + } + var n int + err := s.pool.QueryRow(ctx, + fmt.Sprintf(`SELECT count(*) FROM organizations o %s`, where), args...).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count public orgs: %w", err) + } + return n, nil +} + func (s *Store) ListPublicOrgsPage(ctx context.Context, p OrgListParams) ([]OrgSummary, bool, error) { var args []any add := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) } var where []string - // Substring search over name/description/region (trigram-indexed, migration 0033). - if q := strings.TrimSpace(p.Query); q != "" { - ph := add("%" + escapeLikePattern(q) + "%") - where = append(where, fmt.Sprintf( - "(o.name ILIKE %[1]s OR o.description ILIKE %[1]s OR o.region ILIKE %[1]s)", ph)) + if f := orgSearchFilter(p.Query, add); f != "" { + where = append(where, f) } // Ordering + keyset seek. Each seek tuple mirrors its ORDER BY exactly.