Monorepo for Tangled tangled.org

appview/issues: add sort options for issue list #1121

open opened by murex.tngl.sh targeting master from murex.tngl.sh/tangled: feat/sort-issues

Support sorting issues by: newest, oldest, recently updated, least recently updated, most/least comments, and most/least reactions. Add sort dropdown on the issues page and preserve sort in query params for search, filters, and pagination.

closes: https://tangled.org/tangled.org/core/issues/414

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:owyua2lvxbs55wyhs22dqu2s/sh.tangled.repo.pull/3mgimjgjv3322
+169 -37
Diff #0
+127 -28
appview/db/issues.go
··· 99 99 return nil 100 100 } 101 101 102 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 102 + // IssueSortOption defines sort order for issues. Valid values: "newest", "oldest", 103 + // "recently-updated", "least-recently-updated", "most-comments", "least-comments", 104 + // "most-reactions", "least-reactions". Empty string defaults to "newest". 105 + type IssueSortOption string 106 + 107 + const ( 108 + IssueSortNewest IssueSortOption = "newest" 109 + IssueSortOldest IssueSortOption = "oldest" 110 + IssueSortRecentlyUpdated IssueSortOption = "recently-updated" 111 + IssueSortLeastRecentlyUpdated IssueSortOption = "least-recently-updated" 112 + IssueSortMostComments IssueSortOption = "most-comments" 113 + IssueSortLeastComments IssueSortOption = "least-comments" 114 + IssueSortMostReactions IssueSortOption = "most-reactions" 115 + IssueSortLeastReactions IssueSortOption = "least-reactions" 116 + ) 117 + 118 + func (s IssueSortOption) orderByClause(hasCounts bool) string { 119 + // Use full subquery expressions for count-based sorts - SQLite cannot 120 + // reference column aliases in window function ORDER BY. 121 + commentCountExpr := `(select count(*) from issue_comments c where c.issue_at = i.at_uri and (c.deleted is null or c.deleted = ''))` 122 + reactionCountExpr := `(select count(*) from reactions r where r.thread_at = i.at_uri)` 123 + 124 + switch s { 125 + case IssueSortOldest: 126 + return "i.created asc" 127 + case IssueSortRecentlyUpdated: 128 + return "coalesce(i.edited, i.created) desc" 129 + case IssueSortLeastRecentlyUpdated: 130 + return "coalesce(i.edited, i.created) asc" 131 + case IssueSortMostComments: 132 + if hasCounts { 133 + return commentCountExpr + " desc" 134 + } 135 + return "i.created desc" 136 + case IssueSortLeastComments: 137 + if hasCounts { 138 + return commentCountExpr + " asc" 139 + } 140 + return "i.created desc" 141 + case IssueSortMostReactions: 142 + if hasCounts { 143 + return reactionCountExpr + " desc" 144 + } 145 + return "i.created desc" 146 + case IssueSortLeastReactions: 147 + if hasCounts { 148 + return reactionCountExpr + " asc" 149 + } 150 + return "i.created desc" 151 + default: 152 + return "i.created desc" 153 + } 154 + } 155 + 156 + func (s IssueSortOption) needsCounts() bool { 157 + return s == IssueSortMostComments || s == IssueSortLeastComments || 158 + s == IssueSortMostReactions || s == IssueSortLeastReactions 159 + } 160 + 161 + // ParseIssueSortOption returns the sort option for the given string, or IssueSortNewest if invalid. 162 + func ParseIssueSortOption(v string) IssueSortOption { 163 + switch IssueSortOption(v) { 164 + case IssueSortNewest, IssueSortOldest, IssueSortRecentlyUpdated, IssueSortLeastRecentlyUpdated, 165 + IssueSortMostComments, IssueSortLeastComments, IssueSortMostReactions, IssueSortLeastReactions: 166 + return IssueSortOption(v) 167 + default: 168 + return IssueSortNewest 169 + } 170 + } 171 + 172 + func GetIssuesPaginated(e Execer, page pagination.Page, sort IssueSortOption, filters ...orm.Filter) ([]models.Issue, error) { 103 173 issueMap := make(map[string]*models.Issue) // at-uri -> issue 104 174 175 + if sort == "" { 176 + sort = IssueSortNewest 177 + } 178 + 105 179 var conditions []string 106 180 var args []any 107 181 ··· 125 199 pageClause = " where " + pLower.Condition() + " and " + pUpper.Condition() 126 200 } 127 201 202 + needsCounts := sort.needsCounts() 203 + orderBy := sort.orderByClause(needsCounts) 204 + 205 + var innerSelect string 206 + if needsCounts { 207 + innerSelect = ` 208 + select 209 + i.id, 210 + i.did, 211 + i.rkey, 212 + i.repo_at, 213 + i.issue_id, 214 + i.title, 215 + i.body, 216 + i.open, 217 + i.created, 218 + i.edited, 219 + i.deleted, 220 + (select count(*) from issue_comments c where c.issue_at = i.at_uri and (c.deleted is null or c.deleted = '')) as comment_count, 221 + (select count(*) from reactions r where r.thread_at = i.at_uri) as reaction_count, 222 + row_number() over (order by ` + orderBy + `) as row_num 223 + from issues i` 224 + } else { 225 + innerSelect = ` 226 + select 227 + i.id, 228 + i.did, 229 + i.rkey, 230 + i.repo_at, 231 + i.issue_id, 232 + i.title, 233 + i.body, 234 + i.open, 235 + i.created, 236 + i.edited, 237 + i.deleted, 238 + row_number() over (order by ` + orderBy + `) as row_num 239 + from issues i` 240 + } 241 + fromClause := innerSelect + " " + whereClause 242 + 128 243 query := fmt.Sprintf( 129 244 ` 130 - select * from ( 131 - select 132 - id, 133 - did, 134 - rkey, 135 - repo_at, 136 - issue_id, 137 - title, 138 - body, 139 - open, 140 - created, 141 - edited, 142 - deleted, 143 - row_number() over (order by created desc) as row_num 144 - from 145 - issues 245 + select id, did, rkey, repo_at, issue_id, title, body, open, created, edited, deleted from ( 146 246 %s 147 247 ) ranked_issues 148 248 %s 149 249 `, 150 - whereClause, 250 + fromClause, 151 251 pageClause, 152 252 ) 153 253 ··· 157 257 } 158 258 defer rows.Close() 159 259 260 + issueOrder := make([]string, 0) // preserve order from query 160 261 for rows.Next() { 161 262 var issue models.Issue 162 263 var createdAt string 163 264 var editedAt, deletedAt sql.Null[string] 164 - var rowNum int64 165 265 err := rows.Scan( 166 266 &issue.Id, 167 267 &issue.Did, ··· 174 274 &createdAt, 175 275 &editedAt, 176 276 &deletedAt, 177 - &rowNum, 178 277 ) 179 278 if err != nil { 180 279 return nil, fmt.Errorf("failed to scan issue: %w", err) ··· 198 297 199 298 atUri := issue.AtUri().String() 200 299 issueMap[atUri] = &issue 300 + issueOrder = append(issueOrder, atUri) 201 301 } 202 302 203 303 // collect reverse repos ··· 262 362 } 263 363 } 264 364 265 - var issues []models.Issue 266 - for _, i := range issueMap { 267 - issues = append(issues, *i) 365 + issues := make([]models.Issue, 0, len(issueOrder)) 366 + for _, atUri := range issueOrder { 367 + if i, ok := issueMap[atUri]; ok { 368 + issues = append(issues, *i) 369 + } 268 370 } 269 371 270 - sort.Slice(issues, func(i, j int) bool { 271 - return issues[i].Created.After(issues[j].Created) 272 - }) 273 - 274 372 return issues, nil 275 373 } 276 374 ··· 278 376 issues, err := GetIssuesPaginated( 279 377 e, 280 378 pagination.Page{}, 379 + IssueSortNewest, 281 380 orm.FilterEq("repo_at", repoAt), 282 381 orm.FilterEq("issue_id", issueId), 283 382 ) ··· 292 391 } 293 392 294 393 func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 295 - return GetIssuesPaginated(e, pagination.Page{}, filters...) 394 + return GetIssuesPaginated(e, pagination.Page{}, IssueSortNewest, filters...) 296 395 } 297 396 298 397 func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) {
+1 -1
appview/indexer/issues/indexer.go
··· 172 172 count := 0 173 173 err := pagination.IterateAll( 174 174 func(page pagination.Page) ([]models.Issue, error) { 175 - return db.GetIssuesPaginated(e, page) 175 + return db.GetIssuesPaginated(e, page, db.IssueSortNewest) 176 176 }, 177 177 func(issues []models.Issue) error { 178 178 count += len(issues)
+3 -1
appview/issues/issues.go
··· 894 894 repoInfo := rp.repoResolver.GetRepoInfo(r, user) 895 895 896 896 var issues []models.Issue 897 - 897 + sortOpt := db.ParseIssueSortOption(params.Get("sort")) 898 898 if searchOpts.HasSearchFilters() { 899 899 res, err := rp.indexer.Search(r.Context(), searchOpts) 900 900 if err != nil { ··· 941 941 issues, err = db.GetIssuesPaginated( 942 942 rp.db, 943 943 page, 944 + sortOpt, 944 945 filters..., 945 946 ) 946 947 if err != nil { ··· 983 984 LabelDefs: defs, 984 985 FilterState: filterState, 985 986 FilterQuery: query.String(), 987 + FilterSort: string(sortOpt), 986 988 Page: page, 987 989 }) 988 990 }
+1
appview/pages/pages.go
··· 1013 1013 Page pagination.Page 1014 1014 FilterState string 1015 1015 FilterQuery string 1016 + FilterSort string 1016 1017 } 1017 1018 1018 1019 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
+34 -4
appview/pages/templates/repo/issues/issues.html
··· 26 26 27 27 <div class="grid gap-2 grid-cols-[auto_1fr_auto] grid-row-2"> 28 28 <form id="search-form" class="flex relative col-span-3 sm:col-span-1 sm:col-start-2" method="GET"> 29 + {{ if .FilterState }} 30 + <input type="hidden" name="state" value="{{ .FilterState }}"> 31 + {{ end }} 32 + {{ if .FilterSort }} 33 + <input type="hidden" name="sort" value="{{ .FilterSort }}"> 34 + {{ end }} 29 35 <div class="flex-1 flex relative"> 30 36 <input 31 37 id="search-q" ··· 36 42 placeholder="search issues..." 37 43 > 38 44 <a 39 - {{ if $active }}href="?q=state:{{ $active }}"{{ else }}href="?"{{ end }} 45 + {{ if $active }}href="?q=state:{{ $active }}{{ if .FilterSort }}&sort={{ .FilterSort }}{{ end }}"{{ else }}href="?{{ if .FilterSort }}sort={{ .FilterSort }}{{ end }}"{{ end }} 40 46 class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hidden peer-[:not(:placeholder-shown)]:block" 41 47 > 42 48 {{ i "x" "w-4 h-4" }} ··· 49 55 {{ i "search" "w-4 h-4" }} 50 56 </button> 51 57 </form> 52 - <div class="sm:row-start-1"> 53 - {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q" "Form" "search-form") }} 58 + <div class="sm:row-start-1 flex items-center gap-2"> 59 + {{ template "fragments/tabSelector" (dict "Name" "state" "Values" $values "Active" $active "Include" "#search-q, select[name=sort]" "Form" "search-form") }} 60 + <form id="sort-form" method="GET" class="flex items-center gap-1"> 61 + {{ if .FilterState }} 62 + <input type="hidden" name="state" value="{{ .FilterState }}"> 63 + {{ end }} 64 + {{ if .FilterQuery }} 65 + <input type="hidden" name="q" value="{{ .FilterQuery }}"> 66 + {{ end }} 67 + <label for="sort-select" class="text-sm text-gray-500 dark:text-gray-400 shrink-0">sort:</label> 68 + <select 69 + id="sort-select" 70 + name="sort" 71 + class="text-sm py-1 pl-2 pr-8 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" 72 + onchange="this.form.submit()" 73 + > 74 + <option value="newest" {{ if eq .FilterSort "newest" }}selected{{ end }}>newest</option> 75 + <option value="oldest" {{ if eq .FilterSort "oldest" }}selected{{ end }}>oldest</option> 76 + <option value="recently-updated" {{ if eq .FilterSort "recently-updated" }}selected{{ end }}>recently updated</option> 77 + <option value="least-recently-updated" {{ if eq .FilterSort "least-recently-updated" }}selected{{ end }}>least recently updated</option> 78 + <option value="most-comments" {{ if eq .FilterSort "most-comments" }}selected{{ end }}>most comments</option> 79 + <option value="least-comments" {{ if eq .FilterSort "least-comments" }}selected{{ end }}>least comments</option> 80 + <option value="most-reactions" {{ if eq .FilterSort "most-reactions" }}selected{{ end }}>most reactions</option> 81 + <option value="least-reactions" {{ if eq .FilterSort "least-reactions" }}selected{{ end }}>least reactions</option> 82 + </select> 83 + </form> 54 84 </div> 55 85 <a 56 86 href="/{{ .RepoInfo.FullName }}/issues/new" ··· 72 102 "Page" .Page 73 103 "TotalCount" .IssueCount 74 104 "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 75 - "QueryParams" (queryParams "q" .FilterQuery) 105 + "QueryParams" (queryParams "q" .FilterQuery "state" .FilterState "sort" .FilterSort) 76 106 ) }} 77 107 {{ end }} 78 108 {{ end }}
+1
appview/repo/feed.go
··· 29 29 issues, err := db.GetIssuesPaginated( 30 30 rp.db, 31 31 feedPagePerType, 32 + db.IssueSortNewest, 32 33 orm.FilterEq("repo_at", repo.RepoAt()), 33 34 ) 34 35 if err != nil {
+2 -3
appview/state/gfi.go
··· 53 53 54 54 allIssues, err := db.GetIssuesPaginated( 55 55 s.db, 56 - pagination.Page{ 57 - Limit: 500, 58 - }, 56 + pagination.Page{Limit: 500}, 57 + db.IssueSortNewest, 59 58 orm.FilterIn("repo_at", repoUris), 60 59 orm.FilterEq("open", 1), 61 60 )

History

1 round 1 comment
sign up or login to add to the discussion
murex.tngl.sh submitted #0
1 commit
expand
appview/issues: add sort options for issue list
no conflicts, ready to merge
expand 1 comment

Great work on this @murex.tngl.sh! Thanks so much for making a go at it.

I don't think this is in scope for this change, but in future do you see a good way to sort by the number of a specific kind of reaction? For example, the most thumbs up or thumbs down?