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.
+127
-28
appview/db/issues.go
+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
+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
+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
+1
appview/pages/pages.go
+34
-4
appview/pages/templates/repo/issues/issues.html
+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
+1
appview/repo/feed.go
History
1 round
1 comment
murex.tngl.sh
submitted
#0
1 commit
expand
collapse
appview/issues: add sort options for issue list
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.
Signed-off-by: Nupur Agrawal <nupur202000@gmail.com>
no conflicts, ready to merge
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?