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 · 8d 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/people.go +236−46

@@ -1,17 +1,25 @@
1 1 package web
2 2
3 // The account directory: everyone who has an account here, and what they have
4 // been working on.
3 // The user directory: everyone who has an account here, and what they have been
4 // working on.
5 5 //
6 6 // Account names on this instance are public, which is why this page lists them
7 7 // and why /u/{name} answers for anybody in the roster. Activity is not public:
8 8 // every count beside a name is limited to the repositories the reader may open,
9 9 // so a private repository's work contributes nothing to a stranger's view. The
10 10 // page says so, once, rather than repeating a caveat on every row.
11 //
12 // The table is read server-side. The search, the status filter, the order and the
13 // page all live in the URL, so a view can be linked and paging cannot quietly
14 // change the question. Sorting by a count needs every matched account's summary
15 // before the page is cut, which is why the index answers for the whole directory
16 // in one grouped query rather than one lookup per row.
11 17
12 18 import (
19 "cmp"
13 20 "net/http"
14 21 "net/url"
22 "sort"
15 23 "strconv"
16 24 "strings"
17 25 "time"
@@ -23,8 +31,8 @@ import (
23 31 // peoplePage is how many accounts one page of the directory shows.
24 32 const peoplePage = 50
25 33
26 // peopleRow is one account in the directory. The counts are a summary of the same
27 // events a profile reads, so a row and the page it opens tell the same story.
34 // peopleRow is one account in the table. The counts are the same summary a
35 // profile reads, so a row and the page it opens tell the same story.
28 36 type peopleRow struct {
29 37 Name string
30 38 Active bool
@@ -33,16 +41,171 @@ type peopleRow struct {
33 41 Summary index.ActorSummary
34 42 }
35 43
44 // peopleColumn is one sortable header. Href orders by this column: it flips the
45 // direction when the column is already the order, and takes the column's natural
46 // direction otherwise.
47 type peopleColumn struct {
48 Key string
49 Label string
50 Href string
51 Active bool
52 Desc bool
53 Numeric bool
54 }
55
56 // peopleFilter is one chip above the table: a status the directory can be
57 // limited to, with the search and the order kept.
58 type peopleFilter struct {
59 Label string
60 Href string
61 Active bool
62 }
63
36 64 type peopleView struct {
37 65 Page
38 Query string
39 Rows []peopleRow
40 Retired []peopleRow
41 Pager pager
42 // Total is every account on the instance, or the number that matched a
43 // search, so the subtitle can say which question was answered.
66 Query string
67 Status string
68 Columns []peopleColumn
69 Filters []peopleFilter
70 Rows []peopleRow
71 Pager pager
44 72 Total int
45 73 Filtered bool
74 // Sort and Dir are what the search form has to carry so a search does not
75 // reset the order. They are empty for the default order, which keeps the
76 // common URL short.
77 Sort string
78 Dir string
79 ShowRole bool
80 }
81
82 // peopleSort is the order the table is read in.
83 type peopleSort struct {
84 key string
85 desc bool
86 }
87
88 // peopleSortable is every column the directory can be ordered by.
89 const (
90 sortName = "name"
91 sortRepos = "repos"
92 sortIssues = "issues"
93 sortChanges = "changes"
94 sortReviews = "reviews"
95 sortJoined = "joined"
96 )
97
98 func parsePeopleSort(key, dir string) peopleSort {
99 o := peopleSort{key: sortName}
100 switch key {
101 case sortName, sortRepos, sortIssues, sortChanges, sortReviews, sortJoined:
102 o.key = key
103 }
104 switch dir {
105 case "asc":
106 o.desc = false
107 case "desc":
108 o.desc = true
109 default:
110 o.desc = peopleSortDescFirst(o.key)
111 }
112 return o
113 }
114
115 // peopleSortDescFirst is the direction a column takes the first time it is
116 // chosen: a count is most useful with the largest first, a joining date with the
117 // newest first, and a name from A to Z.
118 func peopleSortDescFirst(key string) bool {
119 return key != sortName
120 }
121
122 // peopleStatus narrows the table to active or retired accounts. An unknown value
123 // is the whole directory rather than an error, so a stale link still shows
124 // something.
125 func parsePeopleStatus(value string) string {
126 switch value {
127 case "active", "retired":
128 return value
129 }
130 return ""
131 }
132
133 // peopleURL builds a directory link. The first page and the default order are
134 // left out, so the URL a reader copies is the short one.
135 func peopleURL(query, status string, order peopleSort, page int) string {
136 v := url.Values{}
137 if query != "" {
138 v.Set("q", query)
139 }
140 if status != "" {
141 v.Set("status", status)
142 }
143 if order.key != sortName || order.desc {
144 v.Set("sort", order.key)
145 if order.desc {
146 v.Set("dir", "desc")
147 } else {
148 v.Set("dir", "asc")
149 }
150 }
151 if page > 1 {
152 v.Set("page", strconv.Itoa(page))
153 }
154 if q := v.Encode(); q != "" {
155 return "/u?" + q
156 }
157 return "/u"
158 }
159
160 // peopleColumns builds the header row: one column per sortable field, each with
161 // the link that orders by it.
162 func peopleColumns(query, status string, order peopleSort) []peopleColumn {
163 defs := []struct {
164 key string
165 label string
166 numeric bool
167 }{
168 {sortName, "User", false},
169 {sortRepos, "Repositories", true},
170 {sortIssues, "Issues", true},
171 {sortChanges, "Changes", true},
172 {sortReviews, "Reviews", true},
173 {sortJoined, "Joined", false},
174 }
175 cols := make([]peopleColumn, 0, len(defs))
176 for _, d := range defs {
177 next := order
178 if order.key == d.key {
179 next.desc = !order.desc
180 } else {
181 next.key = d.key
182 next.desc = peopleSortDescFirst(d.key)
183 }
184 cols = append(cols, peopleColumn{
185 Key: d.key, Label: d.label, Numeric: d.numeric,
186 Href: peopleURL(query, status, next, 1),
187 Active: order.key == d.key, Desc: order.desc,
188 })
189 }
190 return cols
191 }
192
193 // peopleFilters builds the status chips, each keeping the search and the order.
194 func peopleFilters(query string, order peopleSort, status string) []peopleFilter {
195 defs := []struct{ value, label string }{
196 {"", "Everyone"},
197 {"active", "Active"},
198 {"retired", "No longer active"},
199 }
200 out := make([]peopleFilter, 0, len(defs))
201 for _, d := range defs {
202 out = append(out, peopleFilter{
203 Label: d.label,
204 Href: peopleURL(query, d.value, order, 1),
205 Active: d.value == status,
206 })
207 }
208 return out
46 209 }
47 210
48 211 // PeoplePage serves GET /u, the directory of accounts.
@@ -52,39 +215,37 @@ func (w *Web) PeoplePage(rw http.ResponseWriter, r *http.Request) {
52 215 return
53 216 }
54 217 query := strings.TrimSpace(r.URL.Query().Get("q"))
218 status := parsePeopleStatus(r.URL.Query().Get("status"))
219 order := parsePeopleSort(r.URL.Query().Get("sort"), r.URL.Query().Get("dir"))
55 220 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
56 221
57 // All reads the roster once and already orders it active first, then by name:
58 // people who are here are found by eye, and a returning reader finds a name
59 // where it was last time.
222 // All reads the roster once. The filter and the order are applied here rather
223 // than in a store because the roster is already in memory as a snapshot.
60 224 accounts := w.Ident.All()
61 matched := make([]*identity.Account, 0, len(accounts))
62 225 needle := strings.ToLower(query)
226 matched := make([]*identity.Account, 0, len(accounts))
63 227 for _, acct := range accounts {
228 if status != "" && acct.Status != status {
229 continue
230 }
64 231 if needle != "" && !strings.Contains(strings.ToLower(acct.Name), needle) {
65 232 continue
66 233 }
67 234 matched = append(matched, acct)
68 235 }
69 236
70 p := newPager(len(matched), peoplePage, number, peopleURL(query))
71 p.Label = "People pages"
72 from, to := p.rows()
73 visible := matched[from:to]
74
237 // The counts are read for every matched account, not only the visible page:
238 // the order can be by a count, and a page cannot be sorted by numbers the row
239 // does not have.
75 240 summaries := map[string]index.ActorSummary{}
76 if w.Index != nil && len(visible) > 0 {
77 names := make([]string, 0, len(visible))
78 for _, acct := range visible {
79 names = append(names, acct.Name)
80 }
241 if w.Index != nil && len(matched) > 0 {
81 242 entries, err := w.visibleRepos(r, "")
82 243 if err != nil {
83 244 w.serverError(rw, r, "index unavailable: "+err.Error())
84 245 return
85 246 }
86 247 repoIDs, _ := w.visibleIndexedRepos(memoOf(r), entries)
87 if summaries, err = w.Index.ActorSummary(repoIDs, names); err != nil {
248 if summaries, err = w.Index.ActorSummaries(repoIDs); err != nil {
88 249 w.serverError(rw, r, "cannot read the directory: "+err.Error())
89 250 return
90 251 }
@@ -92,10 +253,8 @@ func (w *Web) PeoplePage(rw http.ResponseWriter, r *http.Request) {
92 253
93 254 page := w.page(r, "Users")
94 255 page.NoIndex = true
95 view := peopleView{
96 Page: page, Query: query, Total: len(matched), Filtered: query != "", Pager: p,
97 }
98 for _, acct := range visible {
256 rows := make([]peopleRow, 0, len(matched))
257 for _, acct := range matched {
99 258 row := peopleRow{
100 259 Name: acct.Name, Active: acct.Active(), Joined: acct.Created,
101 260 Summary: summaries[acct.Name],
@@ -103,29 +262,60 @@ func (w *Web) PeoplePage(rw http.ResponseWriter, r *http.Request) {
103 262 if page.InstanceAdmin {
104 263 row.Role = acct.Role.String()
105 264 }
106 if row.Active {
107 view.Rows = append(view.Rows, row)
108 } else {
109 view.Retired = append(view.Retired, row)
265 rows = append(rows, row)
266 }
267 sortPeople(rows, order)
268
269 p := newPager(len(rows), peoplePage, number, func(n int) string {
270 return peopleURL(query, status, order, n)
271 })
272 p.Label = "User pages"
273 from, to := p.rows()
274
275 view := peopleView{
276 Page: page, Query: query, Status: status, Pager: p,
277 Total: len(rows), Filtered: query != "" || status != "",
278 Columns: peopleColumns(query, status, order),
279 Filters: peopleFilters(query, order, status),
280 Rows: rows[from:to], ShowRole: page.InstanceAdmin,
281 }
282 if order.key != sortName || order.desc {
283 view.Sort = order.key
284 view.Dir = "asc"
285 if order.desc {
286 view.Dir = "desc"
110 287 }
111 288 }
112 289 w.render(rw, "people", view, 0)
113 290 }
114 291
115 // peopleURL builds a page link that keeps the search, so paging never silently
116 // widens what the reader asked for.
117 func peopleURL(query string) func(int) string {
118 return func(n int) string {
119 v := url.Values{}
120 if query != "" {
121 v.Set("q", query)
292 // sortPeople orders the rows. Equal values fall back to the name, so the table
293 // does not shuffle between requests that ask the same question.
294 func sortPeople(rows []peopleRow, order peopleSort) {
295 sort.SliceStable(rows, func(i, j int) bool {
296 c := comparePeople(order.key, rows[i], rows[j])
297 if c == 0 {
298 return rows[i].Name < rows[j].Name
122 299 }
123 if n > 1 {
124 v.Set("page", strconv.Itoa(n))
300 if order.desc {
301 return c > 0
125 302 }
126 if q := v.Encode(); q != "" {
127 return "/u?" + q
128 }
129 return "/u"
303 return c < 0
304 })
305 }
306
307 func comparePeople(key string, a, b peopleRow) int {
308 switch key {
309 case sortRepos:
310 return cmp.Compare(a.Summary.Repos, b.Summary.Repos)
311 case sortIssues:
312 return cmp.Compare(a.Summary.Issues, b.Summary.Issues)
313 case sortChanges:
314 return cmp.Compare(a.Summary.Changes, b.Summary.Changes)
315 case sortReviews:
316 return cmp.Compare(a.Summary.Reviews, b.Summary.Reviews)
317 case sortJoined:
318 return a.Joined.Compare(b.Joined)
130 319 }
320 return strings.Compare(a.Name, b.Name)
131 321 }