Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Issues, labels and assignees (M5).
2
3use chrono::{DateTime, Utc};
4use maud::{html, Markup, PreEscaped};
5
6use crate::repo_ctx::RepoContext;
7use crate::views::review::CommentRow;
8
9pub struct Label {
10 pub name: String,
11 pub color: String,
12}
13
14/// A label chip.
15///
16/// The colour is repository-controlled, so it reaches a `style` attribute — the
17/// one place in the product where that is true. It is validated as a hex triple
18/// before it gets here; `label_chip_rejects_a_non_colour` pins that anything
19/// else falls back to the theme rather than being emitted.
20pub fn label_chip(l: &Label) -> Markup {
21 let safe = valid_hex(&l.color);
22 html! {
23 span .chip.label-chip
24 style=[safe.then(|| format!("border-color:{0};color:{0}", l.color))] {
25 (l.name)
26 }
27 }
28}
29
30/// Whether a string is a `#rrggbb` colour and nothing else.
31pub fn valid_hex(s: &str) -> bool {
32 s.len() == 7
33 && s.starts_with('#')
34 && s[1..].bytes().all(|b| b.is_ascii_hexdigit())
35}
36
37pub struct IssueRow {
38 pub number: i64,
39 pub title: String,
40 pub state: String,
41 pub author: Option<String>,
42 pub updated_at: DateTime<Utc>,
43 pub comment_count: i64,
44 pub labels: Vec<Label>,
45 pub assignees: Vec<String>,
46}
47
48pub struct ListFilters<'a> {
49 pub state: &'a str,
50 pub label: Option<&'a str>,
51 pub assignee: Option<&'a str>,
52 pub all_labels: &'a [Label],
53}
54
55/// The issue list.
56///
57/// One row per issue, on the same grammar as the change list: a state glyph, a
58/// number, the title, and metadata pinned to the right. The linked-change
59/// column is the one thing here a general issue tracker does not have — an
60/// issue closes when a change that references it merges, so the change is the
61/// most useful thing to show beside it.
62pub fn list(ctx: &RepoContext, rows: &[IssueRow], f: ListFilters<'_>) -> Markup {
63 let base = ctx.base();
64 let now = Utc::now();
65
66 html! {
67 div .page-head {
68 h1 { "Issues" }
69 span .band-note {
70 "An issue closes when a change that references it merges."
71 }
72 span .spacer {}
73 a .btn.btn-primary href=(format!("{base}/issues/new")) { "New issue" }
74 }
75
76 form .filterbar method="get" action=(format!("{base}/issues")) {
77 div .filterbar-tabs {
78 @for (key, label) in [("open", "Open"), ("closed", "Closed"), ("all", "All")] {
79 a .btn.btn-mono .is-on[f.state == key]
80 href=(format!("{base}/issues?state={key}"))
81 aria-current=[(f.state == key).then_some("page")] {
82 (label)
83 }
84 }
85 }
86 span .spacer {}
87 input type="hidden" name="state" value=(f.state);
88 select name="label" aria-label="Filter by label" {
89 option value="" { "any label" }
90 @for l in f.all_labels {
91 option value=(l.name) selected[f.label == Some(l.name.as_str())] { (l.name) }
92 }
93 }
94 input type="text" name="assignee" value=[f.assignee]
95 placeholder="assignee" aria-label="Filter by assignee";
96 button .btn.btn-mono type="submit" { "filter" }
97 }
98
99 @if rows.is_empty() {
100 div .empty {
101 h2 { "No issues" }
102 p { "Nothing matches this filter." }
103 }
104 } @else {
105 div .filelist {
106 @for r in rows {
107 @let closed = r.state == "closed";
108 a .issue-row .is-closed[closed] href=(format!("{base}/issues/{}", r.number)) {
109 span .issue-glyph aria-hidden="true"
110 style=(format!("color:{}",
111 if closed { "var(--merged)" } else { "var(--open)" })) {
112 @if closed { "⤳" } @else { "○" }
113 }
114 span .issue-num { "#" (r.number) }
115 span .issue-title { (r.title) }
116 @for l in &r.labels { (label_chip(l)) }
117 span .spacer {}
118 @if !r.assignees.is_empty() {
119 span .issue-meta {
120 "→ "
121 @for (i, a) in r.assignees.iter().enumerate() {
122 @if i > 0 { ", " }
123 (a)
124 }
125 }
126 }
127 @if let Some(a) = &r.author {
128 span .issue-meta { (a) }
129 }
130 @if r.comment_count > 0 {
131 span .issue-comments title="comments" { (r.comment_count) "⌾" }
132 }
133 span .issue-when
134 title=(r.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
135 (crate::views::relative_time(r.updated_at, now))
136 }
137 }
138 }
139 }
140 }
141 }
142}
143
144pub struct NewIssue<'a> {
145 pub csrf: &'a str,
146 pub labels: &'a [Label],
147 pub error: Option<&'a str>,
148}
149
150pub fn new_form(ctx: &RepoContext, n: NewIssue<'_>) -> Markup {
151 let base = ctx.base();
152 html! {
153 div .panel {
154 h1 { "New issue" }
155 @if let Some(e) = n.error { div .banner.banner-error role="alert" { (e) } }
156
157 form method="post" action=(format!("{base}/issues")) .stack {
158 input type="hidden" name="_csrf" value=(n.csrf);
159 div .field {
160 label for="title" { "Title" }
161 input type="text" id="title" name="title" required maxlength="300" autofocus;
162 }
163 div .field {
164 label for="body" { "Description" }
165 textarea id="body" name="body" rows="8"
166 placeholder="Markdown is supported. #123 links an issue, @handle a person." {}
167 }
168 @if !n.labels.is_empty() {
169 fieldset style="border:none;padding:0;margin:0" {
170 legend .label-condensed { "Labels" }
171 div .row style="flex-wrap:wrap;gap:12px" {
172 @for l in n.labels {
173 label {
174 input type="checkbox" name="labels" value=(l.name);
175 " " (l.name)
176 }
177 }
178 }
179 }
180 }
181 button .btn.btn-primary type="submit" { "Open issue" }
182 }
183 }
184 }
185}
186
187pub struct Detail<'a> {
188 pub number: i64,
189 pub title: &'a str,
190 pub body_html: &'a str,
191 pub state: &'a str,
192 pub author: Option<&'a str>,
193 pub created_at: DateTime<Utc>,
194 pub labels: &'a [Label],
195 pub all_labels: &'a [Label],
196 pub assignees: &'a [String],
197 pub comments: &'a [CommentRow],
198 /// Changes and issues that reference this one.
199 pub referenced_by: &'a [(String, i64, String)],
200 pub can_comment: bool,
201 pub can_manage: bool,
202 pub csrf: &'a str,
203}
204
205pub fn detail(ctx: &RepoContext, d: Detail<'_>) -> Markup {
206 let base = format!("{}/issues/{}", ctx.base(), d.number);
207 let closed = d.state == "closed";
208
209 let main = html! {
210 a .backlink href=(format!("{}/issues", ctx.base())) { "← issues" }
211
212 div .issue-head {
213 span .badge .badge-merged[closed] .badge-open[!closed] {
214 span .glyph aria-hidden="true" { @if closed { "⤳" } @else { "○" } }
215 @if closed { "closed" } @else { "open" }
216 }
217 span .faint.mono { "#" (d.number) }
218 }
219 h1 .measure { (d.title) }
220
221 @if !d.body_html.is_empty() {
222 div .issue-body.markdown-body { (PreEscaped(d.body_html)) }
223 }
224
225 // The line that states the product's rule about issues: they close
226 // because work landed, not because somebody ticked a box.
227 @for (kind, number, title) in d.referenced_by {
228 div .issue-ref {
229 span .issue-ref-rail aria-hidden="true" { "▌" }
230 span {
231 "referenced by "
232 a href=(format!("{}/{}s/{number}", ctx.base(), kind)) {
233 (kind) " #" (number) " · " (title)
234 }
235 @if kind == "change" && !closed { " — closes on merge" }
236 }
237 }
238 }
239
240 @if d.can_manage {
241 div .panel {
242 h2 { "Manage" }
243
244 form method="post" action=(format!("{base}/labels")) .stack {
245 input type="hidden" name="_csrf" value=(d.csrf);
246 fieldset style="border:none;padding:0;margin:0" {
247 legend .label-condensed { "Labels" }
248 div .row style="flex-wrap:wrap;gap:12px" {
249 @for l in d.all_labels {
250 label {
251 input type="checkbox" name="labels" value=(l.name)
252 checked[d.labels.iter().any(|x| x.name == l.name)];
253 " " (l.name)
254 }
255 }
256 }
257 }
258 button .btn type="submit" { "Save labels" }
259 }
260
261 form method="post" action=(format!("{base}/assignees")) .stack style="margin-top:16px" {
262 input type="hidden" name="_csrf" value=(d.csrf);
263 div .field {
264 label for="assignees" { "Assignees" }
265 input type="text" id="assignees" name="assignees"
266 value=(d.assignees.join(", "))
267 placeholder="handles, comma separated";
268 }
269 button .btn type="submit" { "Save assignees" }
270 }
271 }
272 }
273
274 div .panel {
275 h2 { "Discussion" }
276 @if d.comments.is_empty() {
277 p .hint { "No comments yet." }
278 }
279 div .stack {
280 @for c in d.comments { (issue_comment(c)) }
281 }
282
283 @if d.can_comment {
284 form method="post" action=(format!("{base}/comments")) .stack style="margin-top:20px" {
285 input type="hidden" name="_csrf" value=(d.csrf);
286 div .field {
287 label for="body" { "Comment" }
288 textarea id="body" name="body" rows="4" required {}
289 }
290 div .row {
291 button .btn.btn-primary type="submit" { "Comment" }
292 @if d.can_manage {
293 button .btn type="submit" name="state"
294 value=(if d.state == "closed" { "open" } else { "closed" }) {
295 @if d.state == "closed" { "Comment and reopen" } @else { "Comment and close" }
296 }
297 }
298 }
299 }
300 } @else {
301 p .hint { "Sign in to comment." }
302 }
303
304 @if d.can_manage {
305 form method="post" action=(format!("{base}/state")) style="margin-top:12px" {
306 input type="hidden" name="_csrf" value=(d.csrf);
307 input type="hidden" name="state"
308 value=(if d.state == "closed" { "open" } else { "closed" });
309 button .btn type="submit" {
310 @if d.state == "closed" { "Reopen issue" } @else { "Close issue" }
311 }
312 }
313 }
314 }
315 };
316
317 html! {
318 div .columns.columns-repo {
319 div .columns-main { (main) }
320 aside .columns-aside.is-sticky {
321 div .aside-block {
322 div .label-condensed { "Details" }
323 @if let Some(a) = d.author {
324 div .dotline {
325 span .dotline-key { "Author" }
326 span .dotline-val { (crate::views::user_link(a)) }
327 }
328 }
329 div .dotline {
330 span .dotline-key { "Opened" }
331 span .dotline-val
332 title=(d.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
333 (crate::views::relative_time(d.created_at, Utc::now())) " ago"
334 }
335 }
336 div .dotline {
337 span .dotline-key { "Comments" }
338 span .dotline-val { (d.comments.len()) }
339 }
340 }
341
342 @if !d.labels.is_empty() {
343 div .aside-block {
344 div .label-condensed { "Labels" }
345 div .aside-chips {
346 @for l in d.labels { (label_chip(l)) }
347 }
348 }
349 }
350
351 @if !d.assignees.is_empty() {
352 div .aside-block {
353 div .label-condensed { "Assignees" }
354 @for a in d.assignees {
355 div { (crate::views::user_link(a)) }
356 }
357 }
358 }
359 }
360 }
361 }
362}
363
364fn issue_comment(c: &CommentRow) -> Markup {
365 html! {
366 div .comment {
367 div .row {
368 strong { (crate::views::user_link(&c.author)) }
369 span .faint { (c.created_at.format("%Y-%m-%d %H:%M").to_string()) }
370 @if c.edited { span .faint { "edited" } }
371 }
372 div .comment-body.markdown-body { (PreEscaped(&c.body_html)) }
373 }
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 fn label(color: &str) -> Label {
382 Label { name: "bug".into(), color: color.into() }
383 }
384
385 #[test]
386 fn a_valid_colour_reaches_the_style_attribute() {
387 let m = label_chip(&label("#d06b6b")).into_string();
388 assert!(m.contains("border-color:#d06b6b"), "{m}");
389 }
390
391 /// A label colour is the one repository-controlled value that reaches a
392 /// `style` attribute. Anything that is not a hex triple must be dropped,
393 /// not escaped and emitted.
394 #[test]
395 fn label_chip_rejects_a_non_colour() {
396 for bad in [
397 "red; background:url(javascript:alert(1))",
398 "#zzzzzz",
399 "#fff",
400 "",
401 "expression(alert(1))",
402 ] {
403 let m = label_chip(&label(bad)).into_string();
404 assert!(!m.contains("style="), "{bad} reached a style attribute: {m}");
405 assert!(m.contains("bug"), "the label name must still render");
406 }
407 }
408
409 #[test]
410 fn hex_validation_is_exact() {
411 assert!(valid_hex("#000000"));
412 assert!(valid_hex("#AbCdEf"));
413 assert!(!valid_hex("#abc"));
414 assert!(!valid_hex("000000"));
415 assert!(!valid_hex("#0000000"));
416 }
417}

417 lines · Rust