Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +49 −5
| @@ -176,6 +176,31 @@ code, pre, .mono { | |||
| 176 | color: var(--fb-fg); | 176 | color: var(--fb-fg); |
| 177 | white-space: nowrap; | 177 | white-space: nowrap; |
| 178 | } | 178 | } |
| 179 | /* Multi-label dropdown on the new-issue form. */ | ||
| 180 | .label-dropdown { | ||
| 181 | display: inline-block; | ||
| 182 | position: relative; | ||
| 183 | } | ||
| 184 | .label-dropdown > summary { | ||
| 185 | list-style: none; | ||
| 186 | width: auto; | ||
| 187 | cursor: pointer; | ||
| 188 | } | ||
| 189 | .label-dropdown > summary::-webkit-details-marker { | ||
| 190 | display: none; | ||
| 191 | } | ||
| 192 | .label-menu { | ||
| 193 | left: 0; | ||
| 194 | right: auto; | ||
| 195 | display: flex; | ||
| 196 | flex-direction: column; | ||
| 197 | gap: 0.35rem; | ||
| 198 | max-height: 16rem; | ||
| 199 | overflow-y: auto; | ||
| 200 | } | ||
| 201 | .label-menu .checkbox { | ||
| 202 | margin: 0; | ||
| 203 | } | ||
| 179 | .menu-item:hover { | 204 | .menu-item:hover { |
| 180 | background: var(--fb-bg-inset); | 205 | background: var(--fb-bg-inset); |
| 181 | text-decoration: none; | 206 | text-decoration: none; |
| @@ -742,16 +767,25 @@ body.drawer-open .drawer-backdrop { | |||
| 742 | /* Issue/PR dependency lists in the sidebar. */ | 767 | /* Issue/PR dependency lists in the sidebar. */ |
| 743 | .dep-list { | 768 | .dep-list { |
| 744 | list-style: none; | 769 | list-style: none; |
| 770 | padding: 0; | ||
| 745 | display: flex; | 771 | display: flex; |
| 746 | flex-direction: column; | 772 | flex-direction: column; |
| 747 | gap: 0.35rem; | 773 | gap: 0.25rem; |
| 748 | margin-bottom: 0.5rem; | 774 | margin: 0 0 0.75rem; |
| 749 | } | 775 | } |
| 750 | .dep-row { | 776 | .dep-row { |
| 751 | display: flex; | 777 | display: flex; |
| 752 | align-items: center; | 778 | align-items: center; |
| 753 | gap: 0.4rem; | 779 | gap: 0.45rem; |
| 754 | font-size: 0.9em; | 780 | font-size: 0.9em; |
| 781 | padding: 0.3rem 0.5rem; | ||
| 782 | border-radius: var(--fb-radius); | ||
| 783 | } | ||
| 784 | .dep-row:hover { | ||
| 785 | background: var(--fb-bg-inset); | ||
| 786 | } | ||
| 787 | .dep-row > .icon { | ||
| 788 | flex: none; | ||
| 755 | } | 789 | } |
| 756 | .dep-row > a { | 790 | .dep-row > a { |
| 757 | flex: 1; | 791 | flex: 1; |
| @@ -761,14 +795,23 @@ body.drawer-open .drawer-backdrop { | |||
| 761 | text-overflow: ellipsis; | 795 | text-overflow: ellipsis; |
| 762 | white-space: nowrap; | 796 | white-space: nowrap; |
| 763 | } | 797 | } |
| 798 | .dep-row > a:hover { | ||
| 799 | text-decoration: none; | ||
| 800 | color: var(--fb-accent); | ||
| 801 | } | ||
| 764 | .dep-row .icon-btn { | 802 | .dep-row .icon-btn { |
| 765 | width: 1.5rem; | 803 | width: 1.4rem; |
| 766 | height: 1.5rem; | 804 | height: 1.4rem; |
| 767 | flex: none; | 805 | flex: none; |
| 806 | color: var(--fb-fg-muted); | ||
| 807 | } | ||
| 808 | .dep-row .icon-btn:hover { | ||
| 809 | color: var(--fb-danger); | ||
| 768 | } | 810 | } |
| 769 | .dep-add { | 811 | .dep-add { |
| 770 | display: flex; | 812 | display: flex; |
| 771 | gap: 0.4rem; | 813 | gap: 0.4rem; |
| 814 | margin-top: 0.25rem; | ||
| 772 | } | 815 | } |
| 773 | .dep-add input { | 816 | .dep-add input { |
| 774 | flex: 1; | 817 | flex: 1; |
| @@ -909,6 +952,7 @@ input[type="password"], | |||
| 909 | input[type="email"], | 952 | input[type="email"], |
| 910 | input[type="search"], | 953 | input[type="search"], |
| 911 | input[type="url"], | 954 | input[type="url"], |
| 955 | input[type="number"], | ||
| 912 | textarea, | 956 | textarea, |
| 913 | select { | 957 | select { |
| 914 | width: 100%; | 958 | width: 100%; |
assets/fabrica.js +6 −6
| @@ -56,12 +56,12 @@ | |||
| 56 | }); | 56 | }); |
| 57 | }); | 57 | }); |
| 58 | 58 | ||
| 59 | // ---- Auto-submitting selects ---- | 59 | // ---- Auto-submitting controls ---- |
| 60 | // A <select data-autosubmit> submits its form on change; without JS a noscript | 60 | // Any [data-autosubmit] control (select or checkbox) submits its form on |
| 61 | // Apply button does the same. | 61 | // change; without JS a noscript button does the same. |
| 62 | document.querySelectorAll("select[data-autosubmit]").forEach(function (sel) { | 62 | document.querySelectorAll("[data-autosubmit]").forEach(function (el) { |
| 63 | sel.addEventListener("change", function () { | 63 | el.addEventListener("change", function () { |
| 64 | if (sel.form) sel.form.submit(); | 64 | if (el.form) el.form.submit(); |
| 65 | }); | 65 | }); |
| 66 | }); | 66 | }); |
| 67 | 67 | ||
crates/web/src/issues.rs +74 −32
| @@ -172,6 +172,7 @@ pub(crate) async fn new_form( | |||
| 172 | return Ok(Redirect::to("/login").into_response()); | 172 | return Ok(Redirect::to("/login").into_response()); |
| 173 | } | 173 | } |
| 174 | let branches = crate::repo::branch_names(state, &ctx.repo.id).await; | 174 | let branches = crate::repo::branch_names(state, &ctx.repo.id).await; |
| 175 | let repo_labels = state.store.list_labels(&ctx.repo.id).await?; | ||
| 175 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | 176 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); |
| 176 | let body = html! { | 177 | let body = html! { |
| 177 | (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches)) | 178 | (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches)) |
| @@ -183,6 +184,10 @@ pub(crate) async fn new_form( | |||
| 183 | input type="text" id="title" name="title" required autofocus; | 184 | input type="text" id="title" name="title" required autofocus; |
| 184 | label for="body" { "Description" } | 185 | label for="body" { "Description" } |
| 185 | textarea id="body" name="body" rows="8" placeholder="Markdown supported" {} | 186 | textarea id="body" name="body" rows="8" placeholder="Markdown supported" {} |
| 187 | @if !repo_labels.is_empty() { | ||
| 188 | label { "Labels" } | ||
| 189 | (label_dropdown(&repo_labels)) | ||
| 190 | } | ||
| 186 | button class="btn btn-primary" type="submit" { "Create " (kind.noun) } | 191 | button class="btn btn-primary" type="submit" { "Create " (kind.noun) } |
| 187 | } | 192 | } |
| 188 | } | 193 | } |
| @@ -250,17 +255,19 @@ pub(crate) async fn view( | |||
| 250 | @for (c, cauthor) in &comment_rows { | 255 | @for (c, cauthor) in &comment_rows { |
| 251 | (comment_card(cauthor, &c.body, c.created_at)) | 256 | (comment_card(cauthor, &c.body, c.created_at)) |
| 252 | } | 257 | } |
| 253 | @if can_write { | 258 | @if can_write || is_author { |
| 259 | @let comment_form = format!("comment-{}", issue.id); | ||
| 254 | div class="card comment-form" { | 260 | div class="card comment-form" { |
| 255 | form method="post" action=(format!("/issue/{}/comment", issue.id)) { | 261 | // The comment form holds only the textarea; the Comment |
| 262 | // button and the (separate) state form are siblings in | ||
| 263 | // the actions row — never nested forms. | ||
| 264 | form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) { | ||
| 256 | input type="hidden" name="_csrf" value=(csrf); | 265 | input type="hidden" name="_csrf" value=(csrf); |
| 257 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} | 266 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} |
| 258 | div class="comment-actions" { | 267 | } |
| 259 | @if can_write || is_author { | 268 | div class="comment-actions" { |
| 260 | (state_button(&issue, &csrf, kind)) | 269 | (state_button(&issue, &csrf, kind)) |
| 261 | } | 270 | button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" } |
| 262 | button class="btn btn-primary" type="submit" { "Comment" } | ||
| 263 | } | ||
| 264 | } | 271 | } |
| 265 | } | 272 | } |
| 266 | } | 273 | } |
| @@ -339,7 +346,7 @@ pub(crate) fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup { | |||
| 339 | form method="post" action=(format!("/issue/{}/state", issue.id)) class="inline-form" { | 346 | form method="post" action=(format!("/issue/{}/state", issue.id)) class="inline-form" { |
| 340 | input type="hidden" name="_csrf" value=(csrf); | 347 | input type="hidden" name="_csrf" value=(csrf); |
| 341 | input type="hidden" name="state" value=(target); | 348 | input type="hidden" name="state" value=(target); |
| 342 | button class="btn" type="submit" { (label) } | 349 | button class="btn inline-btn" type="submit" { (label) } |
| 343 | } | 350 | } |
| 344 | } | 351 | } |
| 345 | } | 352 | } |
| @@ -367,7 +374,7 @@ pub(crate) async fn assignee_form( | |||
| 367 | option value=(id) selected[issue.assignee_id.as_deref() == Some(id.as_str())] { (name) } | 374 | option value=(id) selected[issue.assignee_id.as_deref() == Some(id.as_str())] { (name) } |
| 368 | } | 375 | } |
| 369 | } | 376 | } |
| 370 | button class="btn" type="submit" { "Set" } | 377 | button class="btn inline-btn" type="submit" { "Set" } |
| 371 | } | 378 | } |
| 372 | } | 379 | } |
| 373 | } | 380 | } |
| @@ -381,16 +388,38 @@ pub(crate) fn label_form( | |||
| 381 | ) -> Markup { | 388 | ) -> Markup { |
| 382 | let is_on = |id: &str| applied.iter().any(|l| l.id == id); | 389 | let is_on = |id: &str| applied.iter().any(|l| l.id == id); |
| 383 | html! { | 390 | html! { |
| 391 | // Each checkbox auto-submits the whole set (the handler replaces the | ||
| 392 | // applied labels); a noscript Apply button keeps it working without JS. | ||
| 384 | form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" { | 393 | form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" { |
| 385 | input type="hidden" name="_csrf" value=(csrf); | 394 | input type="hidden" name="_csrf" value=(csrf); |
| 386 | @for l in repo_labels { | 395 | @for l in repo_labels { |
| 387 | label class="checkbox" { | 396 | label class="checkbox" { |
| 388 | input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)]; | 397 | input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)] |
| 398 | data-autosubmit; | ||
| 389 | " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {} | 399 | " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {} |
| 390 | " " (l.name) | 400 | " " (l.name) |
| 391 | } | 401 | } |
| 392 | } | 402 | } |
| 393 | button class="btn" type="submit" { "Apply labels" } | 403 | noscript { button class="btn inline-btn" type="submit" { "Apply labels" } } |
| 404 | } | ||
| 405 | } | ||
| 406 | } | ||
| 407 | |||
| 408 | /// A `<details>` dropdown of label checkboxes, for selecting several labels when | ||
| 409 | /// opening an issue. The checkboxes submit with the surrounding form. | ||
| 410 | fn label_dropdown(repo_labels: &[Label]) -> Markup { | ||
| 411 | html! { | ||
| 412 | details class="menu label-dropdown" data-menu { | ||
| 413 | summary class="btn" { "Select labels" (icon(Icon::ChevronDown)) } | ||
| 414 | div class="menu-popover label-menu" { | ||
| 415 | @for l in repo_labels { | ||
| 416 | label class="checkbox" { | ||
| 417 | input type="checkbox" name="label" value=(l.id); | ||
| 418 | " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {} | ||
| 419 | " " (l.name) | ||
| 420 | } | ||
| 421 | } | ||
| 422 | } | ||
| 394 | } | 423 | } |
| 395 | } | 424 | } |
| 396 | } | 425 | } |
| @@ -418,7 +447,7 @@ pub(crate) fn deps_section( | |||
| 418 | form method="post" action=(format!("/issue/{}/deps/remove", issue.id)) class="dep-remove" { | 447 | form method="post" action=(format!("/issue/{}/deps/remove", issue.id)) class="dep-remove" { |
| 419 | input type="hidden" name="_csrf" value=(csrf); | 448 | input type="hidden" name="_csrf" value=(csrf); |
| 420 | input type="hidden" name="depends_on" value=(dep.id); | 449 | input type="hidden" name="depends_on" value=(dep.id); |
| 421 | button class="icon-btn" type="submit" aria-label="Remove dependency" { (icon(Icon::X)) } | 450 | button class="icon-btn inline-btn" type="submit" aria-label="Remove dependency" { (icon(Icon::X)) } |
| 422 | } | 451 | } |
| 423 | } | 452 | } |
| 424 | } | 453 | } |
| @@ -436,7 +465,7 @@ pub(crate) fn deps_section( | |||
| 436 | form method="post" action=(format!("/issue/{}/deps/add", issue.id)) class="side-form dep-add" { | 465 | form method="post" action=(format!("/issue/{}/deps/add", issue.id)) class="side-form dep-add" { |
| 437 | input type="hidden" name="_csrf" value=(csrf); | 466 | input type="hidden" name="_csrf" value=(csrf); |
| 438 | input type="number" name="number" min="1" placeholder="#123" aria-label="Depends on number" required; | 467 | input type="number" name="number" min="1" placeholder="#123" aria-label="Depends on number" required; |
| 439 | button class="btn" type="submit" { "Add" } | 468 | button class="btn inline-btn" type="submit" { "Add" } |
| 440 | } | 469 | } |
| 441 | } | 470 | } |
| 442 | @if !dependents.is_empty() { | 471 | @if !dependents.is_empty() { |
| @@ -508,20 +537,6 @@ pub(crate) async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issu | |||
| 508 | format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number) | 537 | format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number) |
| 509 | } | 538 | } |
| 510 | 539 | ||
| 511 | /// New-issue form fields + a query flag for `is_pull`. | ||
| 512 | #[derive(Debug, Deserialize)] | ||
| 513 | pub struct NewIssueForm { | ||
| 514 | /// Title. | ||
| 515 | #[serde(default)] | ||
| 516 | title: String, | ||
| 517 | /// Markdown body. | ||
| 518 | #[serde(default)] | ||
| 519 | body: String, | ||
| 520 | /// CSRF token. | ||
| 521 | #[serde(rename = "_csrf")] | ||
| 522 | csrf: String, | ||
| 523 | } | ||
| 524 | |||
| 525 | /// `?is_pull=` query for the create endpoint. | 540 | /// `?is_pull=` query for the create endpoint. |
| 526 | #[derive(Debug, Default, Deserialize)] | 541 | #[derive(Debug, Default, Deserialize)] |
| 527 | pub struct CreateQuery { | 542 | pub struct CreateQuery { |
| @@ -531,6 +546,9 @@ pub struct CreateQuery { | |||
| 531 | } | 546 | } |
| 532 | 547 | ||
| 533 | /// `POST /issue-new/{repo_id}` — create an issue (PRs use their own flow). | 548 | /// `POST /issue-new/{repo_id}` — create an issue (PRs use their own flow). |
| 549 | /// | ||
| 550 | /// The body is parsed manually because it carries repeated `label` fields, which | ||
| 551 | /// `serde_urlencoded` cannot represent. | ||
| 534 | pub async fn create( | 552 | pub async fn create( |
| 535 | State(state): State<AppState>, | 553 | State(state): State<AppState>, |
| 536 | MaybeUser(viewer): MaybeUser, | 554 | MaybeUser(viewer): MaybeUser, |
| @@ -538,9 +556,20 @@ pub async fn create( | |||
| 538 | headers: HeaderMap, | 556 | headers: HeaderMap, |
| 539 | Path(repo_id): Path<String>, | 557 | Path(repo_id): Path<String>, |
| 540 | Query(q): Query<CreateQuery>, | 558 | Query(q): Query<CreateQuery>, |
| 541 | Form(form): Form<NewIssueForm>, | 559 | body: axum::body::Bytes, |
| 542 | ) -> Response { | 560 | ) -> Response { |
| 543 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | 561 | let (mut title, mut desc, mut csrf) = (String::new(), String::new(), String::new()); |
| 562 | let mut chosen: Vec<String> = Vec::new(); | ||
| 563 | for (k, v) in url::form_urlencoded::parse(&body) { | ||
| 564 | match k.as_ref() { | ||
| 565 | "title" => title = v.into_owned(), | ||
| 566 | "body" => desc = v.into_owned(), | ||
| 567 | "_csrf" => csrf = v.into_owned(), | ||
| 568 | "label" => chosen.push(v.into_owned()), | ||
| 569 | _ => {} | ||
| 570 | } | ||
| 571 | } | ||
| 572 | if verify_csrf(&jar, &headers, Some(&csrf)).is_err() { | ||
| 544 | return AppError::Forbidden.into_response(); | 573 | return AppError::Forbidden.into_response(); |
| 545 | } | 574 | } |
| 546 | let result = async { | 575 | let result = async { |
| @@ -557,14 +586,27 @@ pub async fn create( | |||
| 557 | if access_for(&state, Some(&user), &repo).await? < auth::Access::Read { | 586 | if access_for(&state, Some(&user), &repo).await? < auth::Access::Read { |
| 558 | return Err(AppError::NotFound); | 587 | return Err(AppError::NotFound); |
| 559 | } | 588 | } |
| 560 | let title = form.title.trim(); | 589 | let title = title.trim(); |
| 561 | if title.is_empty() { | 590 | if title.is_empty() { |
| 562 | return Err(AppError::BadRequest("A title is required.".to_string())); | 591 | return Err(AppError::BadRequest("A title is required.".to_string())); |
| 563 | } | 592 | } |
| 564 | let issue = state | 593 | let issue = state |
| 565 | .store | 594 | .store |
| 566 | .create_issue(&repo.id, &user.id, title, form.body.trim(), false) | 595 | .create_issue(&repo.id, &user.id, title, desc.trim(), false) |
| 567 | .await?; | 596 | .await?; |
| 597 | // Apply any selected labels that belong to this repo. | ||
| 598 | if !chosen.is_empty() { | ||
| 599 | let valid: std::collections::HashSet<String> = state | ||
| 600 | .store | ||
| 601 | .list_labels(&repo.id) | ||
| 602 | .await? | ||
| 603 | .into_iter() | ||
| 604 | .map(|l| l.id) | ||
| 605 | .collect(); | ||
| 606 | for id in chosen.iter().filter(|id| valid.contains(*id)) { | ||
| 607 | state.store.add_issue_label(&issue.id, id).await?; | ||
| 608 | } | ||
| 609 | } | ||
| 568 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | 610 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) |
| 569 | } | 611 | } |
| 570 | .await; | 612 | .await; |
crates/web/src/pulls.rs +7 −8
| @@ -154,17 +154,16 @@ pub(crate) async fn view( | |||
| 154 | (comment_card(cauthor, &c.body, c.created_at)) | 154 | (comment_card(cauthor, &c.body, c.created_at)) |
| 155 | } | 155 | } |
| 156 | (merge_panel(&issue, &pr, mergeable, blocked, &csrf)) | 156 | (merge_panel(&issue, &pr, mergeable, blocked, &csrf)) |
| 157 | @if can_write { | 157 | @if can_write || is_author { |
| 158 | @let comment_form = format!("comment-{}", issue.id); | ||
| 158 | div class="card comment-form" { | 159 | div class="card comment-form" { |
| 159 | form method="post" action=(format!("/issue/{}/comment", issue.id)) { | 160 | form id=(comment_form) method="post" action=(format!("/issue/{}/comment", issue.id)) { |
| 160 | input type="hidden" name="_csrf" value=(csrf); | 161 | input type="hidden" name="_csrf" value=(csrf); |
| 161 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} | 162 | textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {} |
| 162 | div class="comment-actions" { | 163 | } |
| 163 | @if can_write || is_author { | 164 | div class="comment-actions" { |
| 164 | (state_button(&issue, &csrf, Kind::pulls())) | 165 | (state_button(&issue, &csrf, Kind::pulls())) |
| 165 | } | 166 | button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" } |
| 166 | button class="btn btn-primary" type="submit" { "Comment" } | ||
| 167 | } | ||
| 168 | } | 167 | } |
| 169 | } | 168 | } |
| 170 | } | 169 | } |
crates/web/src/tests.rs +73 −2
| @@ -697,14 +697,85 @@ async fn issues_open_create_and_view() { | |||
| 697 | // View it. | 697 | // View it. |
| 698 | let req = Request::builder() | 698 | let req = Request::builder() |
| 699 | .uri("/ada/proj/-/issues/1") | 699 | .uri("/ada/proj/-/issues/1") |
| 700 | .header(header::COOKIE, cookie) | 700 | .header(header::COOKIE, cookie.clone()) |
| 701 | .body(Body::empty()) | 701 | .body(Body::empty()) |
| 702 | .unwrap(); | 702 | .unwrap(); |
| 703 | let res = app.oneshot(req).await.unwrap(); | 703 | let res = app.clone().oneshot(req).await.unwrap(); |
| 704 | assert_eq!(res.status(), StatusCode::OK); | 704 | assert_eq!(res.status(), StatusCode::OK); |
| 705 | let html = body_string(res).await; | 705 | let html = body_string(res).await; |
| 706 | assert!(html.contains("First bug"), "issue title shown"); | 706 | assert!(html.contains("First bug"), "issue title shown"); |
| 707 | assert!(html.contains("#1"), "issue number shown"); | 707 | assert!(html.contains("#1"), "issue number shown"); |
| 708 | |||
| 709 | // Commenting works (regression: nested state form used to duplicate _csrf). | ||
| 710 | let issue = state | ||
| 711 | .store | ||
| 712 | .issue_by_number(&repo.id, 1, false) | ||
| 713 | .await | ||
| 714 | .unwrap() | ||
| 715 | .unwrap(); | ||
| 716 | let req = Request::builder() | ||
| 717 | .method("POST") | ||
| 718 | .uri(format!("/issue/{}/comment", issue.id)) | ||
| 719 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
| 720 | .header(header::COOKIE, cookie) | ||
| 721 | .body(Body::from(format!("body=a+comment&_csrf={token}"))) | ||
| 722 | .unwrap(); | ||
| 723 | let res = app.clone().oneshot(req).await.unwrap(); | ||
| 724 | assert_eq!(res.status(), StatusCode::SEE_OTHER, "comment accepted"); | ||
| 725 | assert_eq!(state.store.list_comments(&issue.id).await.unwrap().len(), 1); | ||
| 726 | } | ||
| 727 | |||
| 728 | #[tokio::test] | ||
| 729 | async fn new_issue_can_apply_labels() { | ||
| 730 | let (state, app) = app().await; | ||
| 731 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | ||
| 732 | let repo = state | ||
| 733 | .store | ||
| 734 | .create_repo(NewRepo { | ||
| 735 | owner_id: ada.id.clone(), | ||
| 736 | group_id: None, | ||
| 737 | name: "proj".to_string(), | ||
| 738 | path: "proj".to_string(), | ||
| 739 | description: None, | ||
| 740 | visibility: model::Visibility::Public, | ||
| 741 | default_branch: "main".to_string(), | ||
| 742 | }) | ||
| 743 | .await | ||
| 744 | .unwrap(); | ||
| 745 | let bug = state | ||
| 746 | .store | ||
| 747 | .create_label(&repo.id, "bug", "#ff0000", None) | ||
| 748 | .await | ||
| 749 | .unwrap(); | ||
| 750 | |||
| 751 | let cookie = login(&app).await; | ||
| 752 | let token = cookie | ||
| 753 | .split("fabrica_csrf=") | ||
| 754 | .nth(1) | ||
| 755 | .and_then(|s| s.split(';').next()) | ||
| 756 | .unwrap() | ||
| 757 | .to_string(); | ||
| 758 | let req = Request::builder() | ||
| 759 | .method("POST") | ||
| 760 | .uri(format!("/issue-new/{}", repo.id)) | ||
| 761 | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
| 762 | .header(header::COOKIE, cookie) | ||
| 763 | .body(Body::from(format!( | ||
| 764 | "title=Tagged&body=&label={}&_csrf={token}", | ||
| 765 | bug.id | ||
| 766 | ))) | ||
| 767 | .unwrap(); | ||
| 768 | let res = app.oneshot(req).await.unwrap(); | ||
| 769 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | ||
| 770 | let issue = state | ||
| 771 | .store | ||
| 772 | .issue_by_number(&repo.id, 1, false) | ||
| 773 | .await | ||
| 774 | .unwrap() | ||
| 775 | .unwrap(); | ||
| 776 | let labels = state.store.issue_labels(&issue.id).await.unwrap(); | ||
| 777 | assert_eq!(labels.len(), 1, "label applied on create"); | ||
| 778 | assert_eq!(labels[0].name, "bug"); | ||
| 708 | } | 779 | } |
| 709 | 780 | ||
| 710 | #[tokio::test] | 781 | #[tokio::test] |