Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }