issues.go
⎇
Raw
1package views
2
3import (
4 "strconv"
5 "strings"
6
7 "hearthforge/internal/config"
8 "hearthforge/internal/db"
9 "hearthforge/internal/util"
10
11 g "maragu.dev/gomponents"
12 . "maragu.dev/gomponents/html"
13)
14
15// LabelsQueryParam builds the "&labels=1,2" suffix the issue tabs reuse.
16func LabelsQueryParam(ids []int64) string {
17 if len(ids) == 0 {
18 return ""
19 }
20 parts := make([]string, len(ids))
21 for i, id := range ids {
22 parts[i] = strconv.FormatInt(id, 10)
23 }
24 return "&labels=" + strings.Join(parts, ",")
25}
26
27// CanEdit reports whether user may edit or delete an issue, a patch or one
28// of their comments. Admins always may. Authors may only while it is open.
29func CanEdit(user *db.SessionUser, authorID *int64, status string) bool {
30 if user == nil {
31 return false
32 }
33 return user.IsAdmin || (authorID != nil && *authorID == user.ID && status == "open")
34}
35
36func labelBadge(label db.Label) g.Node {
37 return Span(Class("label-badge"),
38 Style("background:"+label.Color+";color:"+util.LabelTextColor(label.Color)),
39 g.Text(label.Name))
40}
41
42// IssueList renders the issue index with status tabs, label filter and pages.
43func IssueList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, issues []db.Issue,
44 status string, counts map[string]int, page PageInfo, repoLabels []db.Label,
45 selectedLabelIDs []int64, labelsByIssueID map[int64][]db.Label,
46) g.Node {
47 labelsParam := LabelsQueryParam(selectedLabelIDs)
48 tab := func(key, label string) g.Node {
49 class := "list-tab"
50 if status == key {
51 class += " active"
52 }
53 return A(Href("/"+repo.Name+"/issues?status="+key+labelsParam), Class(class),
54 g.Text(label+" "),
55 g.If(counts[key] > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(counts[key])))),
56 )
57 }
58 selected := make(map[int64]bool, len(selectedLabelIDs))
59 for _, id := range selectedLabelIDs {
60 selected[id] = true
61 }
62 return Layout(Page{Title: "Issues — " + repo.Name, User: user, Cfg: cfg},
63 Div(Class("container"),
64 RepoHeader(repo),
65 RepoNav(cfg, repo, "issues", user),
66 Div(Class("list-header"),
67 Div(Class("list-header-tabs"),
68 tab("open", "Open"),
69 tab("completed", "Completed"),
70 tab("closed", "Closed"),
71 ),
72 Div(Class("list-header-actions"),
73 g.If(len(repoLabels) > 0, Details(Class("label-filter"),
74 Summary(Class("btn btn-secondary btn-sm"),
75 g.Text("Filter by label"),
76 g.If(len(selectedLabelIDs) > 0,
77 Span(Class("tab-count"), g.Text(strconv.Itoa(len(selectedLabelIDs))))),
78 ),
79 Div(Class("label-filter-popup"),
80 Form(Method("GET"), Action("/"+repo.Name+"/issues"),
81 Input(Type("hidden"), Name("status"), Value(status)),
82 Div(Class("label-filter-list"),
83 g.Map(repoLabels, func(label db.Label) g.Node {
84 return Label(Class("label-filter-item"),
85 Input(Type("checkbox"), Name("labels"),
86 Value(strconv.FormatInt(label.ID, 10)),
87 g.If(selected[label.ID], Checked())),
88 labelBadge(label),
89 )
90 }),
91 ),
92 Div(Class("label-filter-actions"),
93 Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Apply")),
94 A(Href("/"+repo.Name+"/issues?status="+status),
95 Class("btn btn-secondary btn-sm"), g.Text("Clear")),
96 ),
97 ),
98 ),
99 )),
100 g.If(user != nil, A(Href("/"+repo.Name+"/issues/new"),
101 Class("btn btn-primary btn-sm"), g.Text("New issue"))),
102 ),
103 ),
104 g.If(len(issues) == 0, Div(Class("empty-state"),
105 P(g.Text("No "+status+" issues.")))),
106 g.If(len(issues) > 0, Ul(Class("issue-list"),
107 g.Map(issues, func(issue db.Issue) g.Node {
108 labels := labelsByIssueID[issue.ID]
109 return Li(Class("issue-item"),
110 Div(Class("issue-main"),
111 Span(Class("issue-status-dot "+issue.Status)),
112 A(Href("/"+repo.Name+"/issues/"+strconv.FormatInt(issue.Number, 10)),
113 Class("issue-title"), g.Text(issue.Title)),
114 Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(issue.Number, 10))),
115 ),
116 g.If(len(labels) > 0, Div(Class("issue-labels"),
117 g.Map(labels, labelBadge))),
118 Div(Class("issue-meta"),
119 Avatar(issue.AuthorID, issue.AuthorAvatarVersion, 20),
120 Span(Class("issue-author"),
121 g.Text("opened by "+util.DisplayName(deref(issue.AuthorUsername), cfg.OwnerDisplayName))),
122 g.El("time", DateTime(issue.CreatedAt), g.Text(util.FormatDate(issue.CreatedAt))),
123 ),
124 )
125 }),
126 )),
127 Pagination(page),
128 ),
129 )
130}
131
132// NewIssue renders the issue creation form.
133func NewIssue(cfg *config.Config, user *db.SessionUser, repo *db.Repo, errMsg, template string,
134 labels []db.Label,
135) g.Node {
136 showLabels := len(labels) > 0 && (user.IsAdmin || repo.AllowUserLabels)
137 return Layout(Page{Title: "New issue — " + repo.Name, User: user, Cfg: cfg},
138 Div(Class("container"),
139 RepoHeader(repo),
140 RepoNav(cfg, repo, "issues", user),
141 H2(Class("section-title"), g.Text("New issue")),
142 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
143 Form(Method("POST"), Action("/"+repo.Name+"/issues"), Class("form-card"),
144 Div(Class("form-group"),
145 Label(For("title"), g.Text("Title")),
146 Input(ID("title"), Name("title"), Type("text"), Required(),
147 MaxLength(strconv.Itoa(cfg.MaxTitleBytes)),
148 Placeholder("Short, descriptive title")),
149 ),
150 Div(Class("form-group"),
151 Label(For("body"), g.Text("Description "),
152 Span(Class("text-muted"), g.Text("(Markdown supported, optional)"))),
153 Textarea(ID("body"), Name("body"), Rows("10"),
154 MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)),
155 Placeholder("Describe the issue..."), g.Text(template)),
156 ),
157 g.If(showLabels, Div(Class("form-group"),
158 Span(Class("form-group-label"), g.Text("Labels")),
159 Div(Class("label-checkbox-list"),
160 g.Map(labels, func(label db.Label) g.Node {
161 return Label(Class("label-checkbox-item"),
162 Input(Type("checkbox"), Name("label_ids"),
163 Value(strconv.FormatInt(label.ID, 10))),
164 labelBadge(label),
165 )
166 }),
167 ),
168 )),
169 Div(Class("form-actions"),
170 Button(Type("submit"), Class("btn btn-primary"), g.Text("Submit issue")),
171 A(Href("/"+repo.Name+"/issues"), Class("btn"), g.Text("Cancel")),
172 ),
173 ),
174 ),
175 )
176}
177
178// IssueDetail renders one issue with its labels, body, reactions and comments.
179func IssueDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, issue *db.Issue,
180 bodyHTML string, comments []ThreadComment, reactions []ReactionCount,
181 commentReactions map[int64][]ReactionCount, issueLabels, repoLabels []db.Label,
182) g.Node {
183 baseURL := "/" + repo.Name + "/issues/" + strconv.FormatInt(issue.Number, 10)
184 isAuthor := user != nil && issue.AuthorID != nil && user.ID == *issue.AuthorID
185 canEditIssue := CanEdit(user, issue.AuthorID, issue.Status)
186 canManageLabels := user != nil && (user.IsAdmin || (repo.AllowUserLabels && isAuthor))
187 isAdmin := user != nil && user.IsAdmin
188 closeLabel := "Reopen issue"
189 if issue.Status == "open" {
190 closeLabel = "Close issue"
191 }
192 return Layout(Page{Title: issue.Title + " — " + repo.Name, User: user, Cfg: cfg},
193 Div(Class("container"),
194 RepoHeader(repo),
195 RepoNav(cfg, repo, "issues", user),
196 Div(Class("issue-detail"),
197 Div(Class("issue-detail-header"),
198 Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(issue.Number, 10))),
199 EditableTitle(cfg, issue.Title, canEditIssue, baseURL+"/edit", "edit_body", issue.Body),
200 Span(Class("issue-badge "+issue.Status), g.Text(issue.Status)),
201 g.If(canEditIssue, Div(Class("issue-detail-meta-actions"),
202 g.If(isAdmin && issue.Status == "open",
203 Form(Method("POST"), Action(baseURL+"/complete"), Class("inline-form"),
204 Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Completed")),
205 )),
206 g.If(isAdmin,
207 Form(Method("POST"), Action(baseURL+"/close"), Class("inline-form"),
208 Button(Type("submit"), Class("btn btn-sm"), g.Text(closeLabel)),
209 )),
210 Details(Class("confirm-details"),
211 Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")),
212 Div(Class("confirm-popup"),
213 g.Text("Permanently delete this issue?"),
214 Form(Method("POST"), Action(baseURL+"/delete"), Class("inline-form"),
215 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")),
216 ),
217 ),
218 ),
219 )),
220 ),
221
222 LabelBadges(issueLabels, repoLabels, canManageLabels, baseURL),
223
224 Div(Class("timeline-item"),
225 Div(Class("timeline-author"),
226 Avatar(issue.AuthorID, issue.AuthorAvatarVersion, 24),
227 Strong(g.Text(util.DisplayName(deref(issue.AuthorUsername), cfg.OwnerDisplayName))),
228 Div(Class("timeline-author-right"),
229 g.If(canEditIssue, Details(Class("inline-edit-details"),
230 Summary(Class("btn btn-xs"),
231 Span(Class("when-closed"), g.Text("Edit")),
232 Span(Class("when-open"), g.Text("Stop editing")),
233 ),
234 )),
235 DateWithEdited(issue.CreatedAt, issue.EditedAt),
236 ),
237 ),
238 g.If(canEditIssue, Div(Class("inline-edit-form-area"),
239 Form(Method("POST"), Action(baseURL+"/edit"), Class("inline-edit-form"),
240 Input(Type("hidden"), Name("title"), Value(issue.Title)),
241 Div(Class("form-group"),
242 Textarea(Class("form-input"), ID("edit-issue-body"), Name("edit_body"),
243 Rows("6"), MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)),
244 g.Text(issue.Body)),
245 ),
246 Div(Class("form-actions"),
247 Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Save")),
248 ),
249 ),
250 )),
251 // The markdown renderer already sanitised this HTML.
252 Div(Class("timeline-body markdown-body"),
253 g.If(bodyHTML != "", g.Raw(bodyHTML)),
254 g.If(bodyHTML == "", Em(Class("text-muted"), g.Text("No description provided."))),
255 ),
256 ReactionBar(reactions, baseURL+"/react", 0, user),
257 ),
258
259 CommentThread(cfg, user, comments, commentReactions, baseURL, issue.Status),
260 ),
261 ),
262 )
263}
264