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