Show org count

This commit is contained in:
Jonathon Leight
2026-07-30 10:57:24 -04:00
parent 4389073d05
commit ba2eb4cb71
4 changed files with 188 additions and 9 deletions
+123
View File
@@ -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), "<no value>") {
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")
}
}
+11
View File
@@ -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)
}
+14 -4
View File
@@ -1,7 +1,19 @@
{{define "title"}}Organizations · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h1 class="page-title fs-1">Organizations</h1></div>
<div class="col">
<h1 class="page-title fs-1">Organizations</h1>
{{/* 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. */}}
<div class="page-subtitle" data-testid="org-count">
{{if .Query}}
{{.Total}} {{if eq .Total 1}}organization{{else}}organizations{{end}} matching “{{.Query}}”
{{else}}
{{.Total}} {{if eq .Total 1}}organization{{else}}organizations{{end}}
{{end}}
</div>
</div>
{{if .UserName}}
<div class="col-auto ms-auto d-print-none">
<a class="btn btn-primary" href="{{.AppURL}}/orgs/new">{{template "icon-plus" "me-1"}}Create organization</a>
@@ -14,7 +26,7 @@
htmx "load more" fragment so appended pages match the first. */}}
{{define "org-rows"}}
{{range .All}}
<a class="list-group-item list-group-item-action" href="/orgs/{{.Slug}}">
<a class="list-group-item list-group-item-action" href="/orgs/{{.Slug}}" data-testid="org-row">
<div class="d-flex align-items-center gap-2">
<span class="fw-bold">{{.Name}}</span>
{{if .Region}}<span class="badge bg-secondary-lt">{{.Region}}</span>{{end}}
@@ -84,7 +96,5 @@
{{end}}
</div>
</div>
</div>
{{end}}
+40 -5
View File
@@ -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.