mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Show org count
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user