Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
Matt W1//! Issues, labels and assignees (M5).
Matt W2
Matt W3use chrono::{DateTime, Utc};
Matt W4use maud::{html, Markup, PreEscaped};
Matt W5
Matt W6use crate::repo_ctx::RepoContext;
Matt W7use crate::views::review::CommentRow;
Matt W8
Matt W9pub struct Label {
Matt W10 pub name: String,
Matt W11 pub color: String,
Matt W12}
Matt W13
Matt W14/// A label chip.
Matt W15///
Matt W16/// The colour is repository-controlled, so it reaches a `style` attribute — the
Matt W17/// one place in the product where that is true. It is validated as a hex triple
Matt W18/// before it gets here; `label_chip_rejects_a_non_colour` pins that anything
Matt W19/// else falls back to the theme rather than being emitted.
Matt W20pub fn label_chip(l: &Label) -> Markup {
Matt W21 let safe = valid_hex(&l.color);
Matt W22 html! {
Matt W23 span .chip.label-chip
Matt W24 style=[safe.then(|| format!("border-color:{0};color:{0}", l.color))] {
Matt W25 (l.name)
Matt W26 }
Matt W27 }
Matt W28}
Matt W29
Matt W30/// Whether a string is a `#rrggbb` colour and nothing else.
Matt W31pub fn valid_hex(s: &str) -> bool {
Matt W32 s.len() == 7
Matt W33 && s.starts_with('#')
Matt W34 && s[1..].bytes().all(|b| b.is_ascii_hexdigit())
Matt W35}
Matt W36
Matt W37pub struct IssueRow {
Matt W38 pub number: i64,
Matt W39 pub title: String,
Matt W40 pub state: String,
Matt W41 pub author: Option<String>,
Matt W42 pub updated_at: DateTime<Utc>,
Matt W43 pub comment_count: i64,
Matt W44 pub labels: Vec<Label>,
Matt W45 pub assignees: Vec<String>,
Matt W46}
Matt W47
Matt W48pub struct ListFilters<'a> {
Matt W49 pub state: &'a str,
Matt W50 pub label: Option<&'a str>,
Matt W51 pub assignee: Option<&'a str>,
Matt W52 pub all_labels: &'a [Label],
Matt W53}
Matt W54
Matt W55/// The issue list.
Matt W56///
Matt W57/// One row per issue, on the same grammar as the change list: a state glyph, a
Matt W58/// number, the title, and metadata pinned to the right. The linked-change
Matt W59/// column is the one thing here a general issue tracker does not have — an
Matt W60/// issue closes when a change that references it merges, so the change is the
Matt W61/// most useful thing to show beside it.
Matt W62pub fn list(ctx: &RepoContext, rows: &[IssueRow], f: ListFilters<'_>) -> Markup {
Matt W63 let base = ctx.base();
Matt W64 let now = Utc::now();
Matt W65
Matt W66 html! {
Matt W67 div .page-head {
Matt W68 h1 { "Issues" }
Matt W69 span .band-note {
Matt W70 "An issue closes when a change that references it merges."
Matt W71 }
Matt W72 span .spacer {}
Matt W73 a .btn.btn-primary href=(format!("{base}/issues/new")) { "New issue" }
Matt W74 }
Matt W75
Matt W76 form .filterbar method="get" action=(format!("{base}/issues")) {
Matt W77 div .filterbar-tabs {
Matt W78 @for (key, label) in [("open", "Open"), ("closed", "Closed"), ("all", "All")] {
Matt W79 a .btn.btn-mono .is-on[f.state == key]
Matt W80 href=(format!("{base}/issues?state={key}"))
Matt W81 aria-current=[(f.state == key).then_some("page")] {
Matt W82 (label)
Matt W83 }
Matt W84 }
Matt W85 }
Matt W86 span .spacer {}
Matt W87 input type="hidden" name="state" value=(f.state);
Matt W88 select name="label" aria-label="Filter by label" {
Matt W89 option value="" { "any label" }
Matt W90 @for l in f.all_labels {
Matt W91 option value=(l.name) selected[f.label == Some(l.name.as_str())] { (l.name) }
Matt W92 }
Matt W93 }
Matt W94 input type="text" name="assignee" value=[f.assignee]
Matt W95 placeholder="assignee" aria-label="Filter by assignee";
Matt W96 button .btn.btn-mono type="submit" { "filter" }
Matt W97 }
Matt W98
Matt W99 @if rows.is_empty() {
Matt W100 div .empty {
Matt W101 h2 { "No issues" }
Matt W102 p { "Nothing matches this filter." }
Matt W103 }
Matt W104 } @else {
Matt W105 div .filelist {
Matt W106 @for r in rows {
Matt W107 @let closed = r.state == "closed";
Matt W108 a .issue-row .is-closed[closed] href=(format!("{base}/issues/{}", r.number)) {
Matt W109 span .issue-glyph aria-hidden="true"
Matt W110 style=(format!("color:{}",
Matt W111 if closed { "var(--merged)" } else { "var(--open)" })) {
Matt W112 @if closed { "⤳" } @else { "○" }
Matt W113 }
Matt W114 span .issue-num { "#" (r.number) }
Matt W115 span .issue-title { (r.title) }
Matt W116 @for l in &r.labels { (label_chip(l)) }
Matt W117 span .spacer {}
Matt W118 @if !r.assignees.is_empty() {
Matt W119 span .issue-meta {
Matt W120 "→ "
Matt W121 @for (i, a) in r.assignees.iter().enumerate() {
Matt W122 @if i > 0 { ", " }
Matt W123 (a)
Matt W124 }
Matt W125 }
Matt W126 }
Matt W127 @if let Some(a) = &r.author {
Matt W128 span .issue-meta { (a) }
Matt W129 }
Matt W130 @if r.comment_count > 0 {
Matt W131 span .issue-comments title="comments" { (r.comment_count) "⌾" }
Matt W132 }
Matt W133 span .issue-when
Matt W134 title=(r.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W135 (crate::views::relative_time(r.updated_at, now))
Matt W136 }
Matt W137 }
Matt W138 }
Matt W139 }
Matt W140 }
Matt W141 }
Matt W142}
Matt W143
Matt W144pub struct NewIssue<'a> {
Matt W145 pub csrf: &'a str,
Matt W146 pub labels: &'a [Label],
Matt W147 pub error: Option<&'a str>,
Matt W148}
Matt W149
Matt W150pub fn new_form(ctx: &RepoContext, n: NewIssue<'_>) -> Markup {
Matt W151 let base = ctx.base();
Matt W152 html! {
Matt W153 div .panel {
Matt W154 h1 { "New issue" }
Matt W155 @if let Some(e) = n.error { div .banner.banner-error role="alert" { (e) } }
Matt W156
Matt W157 form method="post" action=(format!("{base}/issues")) .stack {
Matt W158 input type="hidden" name="_csrf" value=(n.csrf);
Matt W159 div .field {
Matt W160 label for="title" { "Title" }
Matt W161 input type="text" id="title" name="title" required maxlength="300" autofocus;
Matt W162 }
Matt W163 div .field {
Matt W164 label for="body" { "Description" }
Matt W165 textarea id="body" name="body" rows="8"
Matt W166 placeholder="Markdown is supported. #123 links an issue, @handle a person." {}
Matt W167 }
Matt W168 @if !n.labels.is_empty() {
Matt W169 fieldset style="border:none;padding:0;margin:0" {
Matt W170 legend .label-condensed { "Labels" }
Matt W171 div .row style="flex-wrap:wrap;gap:12px" {
Matt W172 @for l in n.labels {
Matt W173 label {
Matt W174 input type="checkbox" name="labels" value=(l.name);
Matt W175 " " (l.name)
Matt W176 }
Matt W177 }
Matt W178 }
Matt W179 }
Matt W180 }
Matt W181 button .btn.btn-primary type="submit" { "Open issue" }
Matt W182 }
Matt W183 }
Matt W184 }
Matt W185}
Matt W186
Matt W187pub struct Detail<'a> {
Matt W188 pub number: i64,
Matt W189 pub title: &'a str,
Matt W190 pub body_html: &'a str,
Matt W191 pub state: &'a str,
Matt W192 pub author: Option<&'a str>,
Matt W193 pub created_at: DateTime<Utc>,
Matt W194 pub labels: &'a [Label],
Matt W195 pub all_labels: &'a [Label],
Matt W196 pub assignees: &'a [String],
Matt W197 pub comments: &'a [CommentRow],
Matt W198 /// Changes and issues that reference this one.
Matt W199 pub referenced_by: &'a [(String, i64, String)],
Matt W200 pub can_comment: bool,
Matt W201 pub can_manage: bool,
Matt W202 pub csrf: &'a str,
Matt W203}
Matt W204
Matt W205pub fn detail(ctx: &RepoContext, d: Detail<'_>) -> Markup {
Matt W206 let base = format!("{}/issues/{}", ctx.base(), d.number);
Matt W207 let closed = d.state == "closed";
Matt W208
Matt W209 let main = html! {
Matt W210 a .backlink href=(format!("{}/issues", ctx.base())) { "← issues" }
Matt W211
Matt W212 div .issue-head {
Matt W213 span .badge .badge-merged[closed] .badge-open[!closed] {
Matt W214 span .glyph aria-hidden="true" { @if closed { "⤳" } @else { "○" } }
Matt W215 @if closed { "closed" } @else { "open" }
Matt W216 }
Matt W217 span .faint.mono { "#" (d.number) }
Matt W218 }
Matt W219 h1 .measure { (d.title) }
Matt W220
Matt W221 @if !d.body_html.is_empty() {
Matt W222 div .issue-body.markdown-body { (PreEscaped(d.body_html)) }
Matt W223 }
Matt W224
Matt W225 // The line that states the product's rule about issues: they close
Matt W226 // because work landed, not because somebody ticked a box.
Matt W227 @for (kind, number, title) in d.referenced_by {
Matt W228 div .issue-ref {
Matt W229 span .issue-ref-rail aria-hidden="true" { "▌" }
Matt W230 span {
Matt W231 "referenced by "
Matt W232 a href=(format!("{}/{}s/{number}", ctx.base(), kind)) {
Matt W233 (kind) " #" (number) " · " (title)
Matt W234 }
Matt W235 @if kind == "change" && !closed { " — closes on merge" }
Matt W236 }
Matt W237 }
Matt W238 }
Matt W239
Matt W240 @if d.can_manage {
Matt W241 div .panel {
Matt W242 h2 { "Manage" }
Matt W243
Matt W244 form method="post" action=(format!("{base}/labels")) .stack {
Matt W245 input type="hidden" name="_csrf" value=(d.csrf);
Matt W246 fieldset style="border:none;padding:0;margin:0" {
Matt W247 legend .label-condensed { "Labels" }
Matt W248 div .row style="flex-wrap:wrap;gap:12px" {
Matt W249 @for l in d.all_labels {
Matt W250 label {
Matt W251 input type="checkbox" name="labels" value=(l.name)
Matt W252 checked[d.labels.iter().any(|x| x.name == l.name)];
Matt W253 " " (l.name)
Matt W254 }
Matt W255 }
Matt W256 }
Matt W257 }
Matt W258 button .btn type="submit" { "Save labels" }
Matt W259 }
Matt W260
Matt W261 form method="post" action=(format!("{base}/assignees")) .stack style="margin-top:16px" {
Matt W262 input type="hidden" name="_csrf" value=(d.csrf);
Matt W263 div .field {
Matt W264 label for="assignees" { "Assignees" }
Matt W265 input type="text" id="assignees" name="assignees"
Matt W266 value=(d.assignees.join(", "))
Matt W267 placeholder="handles, comma separated";
Matt W268 }
Matt W269 button .btn type="submit" { "Save assignees" }
Matt W270 }
Matt W271 }
Matt W272 }
Matt W273
Matt W274 div .panel {
Matt W275 h2 { "Discussion" }
Matt W276 @if d.comments.is_empty() {
Matt W277 p .hint { "No comments yet." }
Matt W278 }
Matt W279 div .stack {
Matt W280 @for c in d.comments { (issue_comment(c)) }
Matt W281 }
Matt W282
Matt W283 @if d.can_comment {
Matt W284 form method="post" action=(format!("{base}/comments")) .stack style="margin-top:20px" {
Matt W285 input type="hidden" name="_csrf" value=(d.csrf);
Matt W286 div .field {
Matt W287 label for="body" { "Comment" }
Matt W288 textarea id="body" name="body" rows="4" required {}
Matt W289 }
Matt W290 div .row {
Matt W291 button .btn.btn-primary type="submit" { "Comment" }
Matt W292 @if d.can_manage {
Matt W293 button .btn type="submit" name="state"
Matt W294 value=(if d.state == "closed" { "open" } else { "closed" }) {
Matt W295 @if d.state == "closed" { "Comment and reopen" } @else { "Comment and close" }
Matt W296 }
Matt W297 }
Matt W298 }
Matt W299 }
Matt W300 } @else {
Matt W301 p .hint { "Sign in to comment." }
Matt W302 }
Matt W303
Matt W304 @if d.can_manage {
Matt W305 form method="post" action=(format!("{base}/state")) style="margin-top:12px" {
Matt W306 input type="hidden" name="_csrf" value=(d.csrf);
Matt W307 input type="hidden" name="state"
Matt W308 value=(if d.state == "closed" { "open" } else { "closed" });
Matt W309 button .btn type="submit" {
Matt W310 @if d.state == "closed" { "Reopen issue" } @else { "Close issue" }
Matt W311 }
Matt W312 }
Matt W313 }
Matt W314 }
Matt W315 };
Matt W316
Matt W317 html! {
Matt W318 div .columns.columns-repo {
Matt W319 div .columns-main { (main) }
Matt W320 aside .columns-aside.is-sticky {
Matt W321 div .aside-block {
Matt W322 div .label-condensed { "Details" }
Matt W323 @if let Some(a) = d.author {
Matt W324 div .dotline {
Matt W325 span .dotline-key { "Author" }
Matt W326 span .dotline-val { (crate::views::user_link(a)) }
Matt W327 }
Matt W328 }
Matt W329 div .dotline {
Matt W330 span .dotline-key { "Opened" }
Matt W331 span .dotline-val
Matt W332 title=(d.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W333 (crate::views::relative_time(d.created_at, Utc::now())) " ago"
Matt W334 }
Matt W335 }
Matt W336 div .dotline {
Matt W337 span .dotline-key { "Comments" }
Matt W338 span .dotline-val { (d.comments.len()) }
Matt W339 }
Matt W340 }
Matt W341
Matt W342 @if !d.labels.is_empty() {
Matt W343 div .aside-block {
Matt W344 div .label-condensed { "Labels" }
Matt W345 div .aside-chips {
Matt W346 @for l in d.labels { (label_chip(l)) }
Matt W347 }
Matt W348 }
Matt W349 }
Matt W350
Matt W351 @if !d.assignees.is_empty() {
Matt W352 div .aside-block {
Matt W353 div .label-condensed { "Assignees" }
Matt W354 @for a in d.assignees {
Matt W355 div { (crate::views::user_link(a)) }
Matt W356 }
Matt W357 }
Matt W358 }
Matt W359 }
Matt W360 }
Matt W361 }
Matt W362}
Matt W363
Matt W364fn issue_comment(c: &CommentRow) -> Markup {
Matt W365 html! {
Matt W366 div .comment {
Matt W367 div .row {
Matt W368 strong { (crate::views::user_link(&c.author)) }
Matt W369 span .faint { (c.created_at.format("%Y-%m-%d %H:%M").to_string()) }
Matt W370 @if c.edited { span .faint { "edited" } }
Matt W371 }
Matt W372 div .comment-body.markdown-body { (PreEscaped(&c.body_html)) }
Matt W373 }
Matt W374 }
Matt W375}
Matt W376
Matt W377#[cfg(test)]
Matt W378mod tests {
Matt W379 use super::*;
Matt W380
Matt W381 fn label(color: &str) -> Label {
Matt W382 Label { name: "bug".into(), color: color.into() }
Matt W383 }
Matt W384
Matt W385 #[test]
Matt W386 fn a_valid_colour_reaches_the_style_attribute() {
Matt W387 let m = label_chip(&label("#d06b6b")).into_string();
Matt W388 assert!(m.contains("border-color:#d06b6b"), "{m}");
Matt W389 }
Matt W390
Matt W391 /// A label colour is the one repository-controlled value that reaches a
Matt W392 /// `style` attribute. Anything that is not a hex triple must be dropped,
Matt W393 /// not escaped and emitted.
Matt W394 #[test]
Matt W395 fn label_chip_rejects_a_non_colour() {
Matt W396 for bad in [
Matt W397 "red; background:url(javascript:alert(1))",
Matt W398 "#zzzzzz",
Matt W399 "#fff",
Matt W400 "",
Matt W401 "expression(alert(1))",
Matt W402 ] {
Matt W403 let m = label_chip(&label(bad)).into_string();
Matt W404 assert!(!m.contains("style="), "{bad} reached a style attribute: {m}");
Matt W405 assert!(m.contains("bug"), "the label name must still render");
Matt W406 }
Matt W407 }
Matt W408
Matt W409 #[test]
Matt W410 fn hex_validation_is_exact() {
Matt W411 assert!(valid_hex("#000000"));
Matt W412 assert!(valid_hex("#AbCdEf"));
Matt W413 assert!(!valid_hex("#abc"));
Matt W414 assert!(!valid_hex("000000"));
Matt W415 assert!(!valid_hex("#0000000"));
Matt W416 }
Matt W417}

417 lines · Rust