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 | } | |