vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Turn the user directory into a sortable table
4739664b94c4321b83a2650a3a2486ec090aed64 · Keenan Nemetz · 7d ago · parent c14588ef
Turn the user directory into a sortable table The list became a table with the same counts, and the column headers order it: user, repositories, issues, changes, reviews, joining date. Counts start largest-first and the name A to Z; clicking the active header flips it. The active column carries aria-sort and an arrow. The search, the status chips (everyone, active, no longer active), the order and the page all live in the URL and are carried into every link, so a view can be bookmarked and paging cannot change the question. It is all server-side, so it works without JavaScript. Sorting by a count needs every matched account's summary before the page is cut, so the index now answers for the whole directory in one grouped query — ActorSummaries — instead of a lookup for a page of names. That also drops the per-request name list, so a large roster cannot run into SQLite's bound-variable limit. Retired accounts are marked in their row and can be filtered to rather than living in a separate group, and a roster larger than one page is covered by a test.
server/web/profile_test.go +133−10
| @@ -7,6 +7,7 @@ package web | |||
| 7 | 7 | ||
| 8 | 8 | import ( | |
| 9 | 9 | "context" | |
| 10 | "fmt" | ||
| 10 | 11 | "net/http" | |
| 11 | 12 | "net/http/httptest" | |
| 12 | 13 | "path/filepath" | |
| @@ -251,22 +252,110 @@ func TestPeopleListsEveryAccountAndFilters(t *testing.T) { | |||
| 251 | 252 | } | |
| 252 | 253 | } | |
| 253 | 254 | ||
| 254 | func TestRetiredAccountsSitApartInTheDirectory(t *testing.T) { | ||
| 255 | // The directory is one table now, so a retired account is marked in its row and | ||
| 256 | // the status chips are how a reader narrows the list to one kind. | ||
| 257 | func TestRetiredAccountsAreMarkedAndFilterable(t *testing.T) { | ||
| 255 | 258 | t.Parallel() | |
| 256 | 259 | s := newSite(t) | |
| 257 | 260 | if _, err := s.ident.Retire("bob", "alice"); err != nil { | |
| 258 | 261 | t.Fatal(err) | |
| 259 | 262 | } | |
| 263 | |||
| 264 | everyone := s.serve(t, nil, "/u").Body.String() | ||
| 265 | if !strings.Contains(everyone, `href="/u/bob"`) || !strings.Contains(everyone, `class="state retired"`) { | ||
| 266 | t.Fatalf("the retired account is not marked in the table:\n%s", everyone) | ||
| 267 | } | ||
| 268 | |||
| 269 | active := s.serve(t, nil, "/u?status=active").Body.String() | ||
| 270 | if strings.Contains(active, `href="/u/bob"`) { | ||
| 271 | t.Fatalf("the active filter kept a retired account:\n%s", active) | ||
| 272 | } | ||
| 273 | retired := s.serve(t, nil, "/u?status=retired").Body.String() | ||
| 274 | if !strings.Contains(retired, `href="/u/bob"`) || strings.Contains(retired, `href="/u/alice"`) { | ||
| 275 | t.Fatalf("the retired filter returned the wrong accounts:\n%s", retired) | ||
| 276 | } | ||
| 277 | } | ||
| 278 | |||
| 279 | // The header is the control: each column links to itself in the other direction, | ||
| 280 | // and the current one is marked for a screen reader. | ||
| 281 | func TestDirectorySortsByItsHeaders(t *testing.T) { | ||
| 282 | t.Parallel() | ||
| 283 | s := newSite(t) | ||
| 284 | |||
| 285 | // Default is the name, A to Z. alice opened the fixture's issues; adam and bob | ||
| 286 | // have none, so a count order is a different question. | ||
| 260 | 287 | body := s.serve(t, nil, "/u").Body.String() | |
| 261 | retired := strings.Index(body, "No longer active") | ||
| 262 | if retired < 0 { | ||
| 263 | t.Fatalf("the directory has no retired group:\n%s", body) | ||
| 288 | adam := strings.Index(body, `href="/u/adam"`) | ||
| 289 | alice := strings.Index(body, `href="/u/alice"`) | ||
| 290 | bob := strings.Index(body, `href="/u/bob"`) | ||
| 291 | if adam < 0 || alice < 0 || bob < 0 || !(adam < alice && alice < bob) { | ||
| 292 | t.Fatalf("the default order is not A to Z: adam=%d alice=%d bob=%d", adam, alice, bob) | ||
| 293 | } | ||
| 294 | if !strings.Contains(body, `aria-sort="ascending"`) { | ||
| 295 | t.Fatalf("the active column is not marked:\n%s", body) | ||
| 296 | } | ||
| 297 | // A count column starts with the largest first when it is first chosen. | ||
| 298 | if !strings.Contains(body, "sort=issues") || !strings.Contains(body, "dir=desc") { | ||
| 299 | t.Fatalf("the Issues header does not ask for the largest first:\n%s", body) | ||
| 300 | } | ||
| 301 | |||
| 302 | ordered := s.serve(t, nil, "/u?sort=issues&dir=desc").Body.String() | ||
| 303 | if strings.Index(ordered, `href="/u/alice"`) > strings.Index(ordered, `href="/u/adam"`) { | ||
| 304 | t.Fatalf("the issues order does not put the only author first:\n%s", ordered) | ||
| 305 | } | ||
| 306 | if !strings.Contains(ordered, `aria-sort="descending"`) { | ||
| 307 | t.Fatalf("the issues order is not marked:\n%s", ordered) | ||
| 308 | } | ||
| 309 | // Clicking the same header again flips it. | ||
| 310 | if !strings.Contains(ordered, "dir=asc") { | ||
| 311 | t.Fatalf("the active header does not flip the direction:\n%s", ordered) | ||
| 312 | } | ||
| 313 | // A search keeps the order it was made in. | ||
| 314 | if !strings.Contains(ordered, `name="sort" value="issues"`) || | ||
| 315 | !strings.Contains(ordered, `name="dir" value="desc"`) { | ||
| 316 | t.Fatalf("the search form drops the order:\n%s", ordered) | ||
| 317 | } | ||
| 318 | } | ||
| 319 | |||
| 320 | func TestParsePeopleSort(t *testing.T) { | ||
| 321 | t.Parallel() | ||
| 322 | cases := []struct { | ||
| 323 | in, dir string | ||
| 324 | key string | ||
| 325 | desc bool | ||
| 326 | }{ | ||
| 327 | {"", "", sortName, false}, | ||
| 328 | {"name", "desc", sortName, true}, | ||
| 329 | {"issues", "desc", sortIssues, true}, | ||
| 330 | {"issues", "asc", sortIssues, false}, | ||
| 331 | // A count starts with the largest first when no direction is given. | ||
| 332 | {"reviews", "", sortReviews, true}, | ||
| 333 | {"joined", "", sortJoined, true}, | ||
| 334 | // An unknown key or direction is the default rather than an error. | ||
| 335 | {"bogus", "sideways", sortName, false}, | ||
| 264 | 336 | } | |
| 265 | if strings.Index(body, `href="/u/alice"`) > retired { | ||
| 266 | t.Fatalf("an active account is listed after the retired group:\n%s", body) | ||
| 337 | for _, c := range cases { | ||
| 338 | got := parsePeopleSort(c.in, c.dir) | ||
| 339 | if got.key != c.key || got.desc != c.desc { | ||
| 340 | t.Errorf("parsePeopleSort(%q, %q) = %+v", c.in, c.dir, got) | ||
| 341 | } | ||
| 267 | 342 | } | |
| 268 | if strings.Index(body, `href="/u/bob"`) < retired { | ||
| 269 | t.Fatalf("a retired account is listed with the active ones:\n%s", body) | ||
| 343 | } | ||
| 344 | |||
| 345 | func TestSortPeopleFallsBackToTheName(t *testing.T) { | ||
| 346 | t.Parallel() | ||
| 347 | rows := []peopleRow{ | ||
| 348 | {Name: "carol", Summary: index.ActorSummary{Issues: 2}}, | ||
| 349 | {Name: "alice", Summary: index.ActorSummary{Issues: 2}}, | ||
| 350 | {Name: "bob", Summary: index.ActorSummary{Issues: 1}}, | ||
| 351 | } | ||
| 352 | sortPeople(rows, peopleSort{key: sortIssues, desc: true}) | ||
| 353 | if rows[0].Name != "alice" || rows[1].Name != "carol" || rows[2].Name != "bob" { | ||
| 354 | t.Fatalf("sorted rows = %v", rows) | ||
| 355 | } | ||
| 356 | sortPeople(rows, peopleSort{key: sortName}) | ||
| 357 | if rows[0].Name != "alice" || rows[1].Name != "bob" || rows[2].Name != "carol" { | ||
| 358 | t.Fatalf("name order = %v", rows) | ||
| 270 | 359 | } | |
| 271 | 360 | } | |
| 272 | 361 | ||
| @@ -340,12 +429,17 @@ func TestProfileAndPeopleURLsKeepTheFilter(t *testing.T) { | |||
| 340 | 429 | if got := profileURL("alice", "reviews")(2); got != "/u/alice?page=2&type=reviews" { | |
| 341 | 430 | t.Fatalf("filtered page = %q", got) | |
| 342 | 431 | } | |
| 343 | if got := peopleURL("")(1); got != "/u" { | ||
| 432 | // The default directory URL stays short. | ||
| 433 | if got := peopleURL("", "", peopleSort{key: sortName}, 1); got != "/u" { | ||
| 344 | 434 | t.Fatalf("directory first page = %q", got) | |
| 345 | 435 | } | |
| 346 | if got := peopleURL("ali")(2); got != "/u?page=2&q=ali" { | ||
| 436 | if got := peopleURL("ali", "", peopleSort{key: sortName}, 2); got != "/u?page=2&q=ali" { | ||
| 347 | 437 | t.Fatalf("searched page = %q", got) | |
| 348 | 438 | } | |
| 439 | // The search, the status and the order all survive a page link. | ||
| 440 | if got := peopleURL("a", "active", peopleSort{key: sortReviews, desc: true}, 2); got != "/u?dir=desc&page=2&q=a&sort=reviews&status=active" { | ||
| 441 | t.Fatalf("filtered and sorted page = %q", got) | ||
| 442 | } | ||
| 349 | 443 | } | |
| 350 | 444 | ||
| 351 | 445 | // TestUserRefLinksOnlyAccounts: a name becomes a link only when it belongs to an | |
| @@ -368,3 +462,32 @@ func TestUserRefLinksOnlyAccounts(t *testing.T) { | |||
| 368 | 462 | t.Fatalf("empty name = %+v", empty) | |
| 369 | 463 | } | |
| 370 | 464 | } | |
| 465 | |||
| 466 | // A directory of a large project pages server-side, and the page link keeps the | ||
| 467 | // question the reader asked: the order, the status and the search. | ||
| 468 | func TestDirectoryPaginates(t *testing.T) { | ||
| 469 | t.Parallel() | ||
| 470 | s := newSite(t) | ||
| 471 | for i := 0; i < peoplePage+5; i++ { | ||
| 472 | if _, _, err := s.ident.CreateWithInvite(fmt.Sprintf("user-%03d", i), "setup", time.Hour); err != nil { | ||
| 473 | t.Fatal(err) | ||
| 474 | } | ||
| 475 | } | ||
| 476 | |||
| 477 | first := s.serve(t, nil, "/u").Body.String() | ||
| 478 | if got := strings.Count(first, `class="people-name"`); got != peoplePage { | ||
| 479 | t.Fatalf("first page shows %d rows, want %d", got, peoplePage) | ||
| 480 | } | ||
| 481 | if !strings.Contains(first, `href="/u?page=2"`) { | ||
| 482 | t.Fatalf("the first page has no link to the second:\n%s", first) | ||
| 483 | } | ||
| 484 | |||
| 485 | second := s.serve(t, nil, "/u?sort=joined&dir=desc&page=2").Body.String() | ||
| 486 | want := 3 + peoplePage + 5 - peoplePage // the fixture's accounts plus the extra ones | ||
| 487 | if got := strings.Count(second, `class="people-name"`); got != want { | ||
| 488 | t.Fatalf("second page shows %d rows, want %d", got, want) | ||
| 489 | } | ||
| 490 | if !strings.Contains(second, "sort=joined") || !strings.Contains(second, "dir=desc") { | ||
| 491 | t.Fatalf("a page link drops the order:\n%s", second) | ||
| 492 | } | ||
| 493 | } | ||