fabrica

hanna/fabrica

fix(web): comment form no longer nests forms; issue label + control fixes

a4ddbe6 · hanna committed on 2026-07-25

Fix the "duplicate field _csrf" error when commenting: the Close/Reopen state
form was nested inside the comment form (invalid HTML). The comment textarea is
now its own form and the Comment button references it via form=, with the state
form a sibling. Style number inputs globally (the "Blocked by" #123 box),
refine dependency rows, opt sidebar/action buttons out of the stacked-form
margin (inline-btn), auto-apply labels on checkbox change, and add a multi-label
dropdown to the new-issue form (labels applied on create).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
5 files changed · +209 −53UnifiedSplit
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"],
909input[type="email"],952input[type="email"],
910input[type="search"],953input[type="search"],
911input[type="url"],954input[type="url"],
955input[type="number"],
912textarea,956textarea,
913select {957select {
914 width: 100%;958 width: 100%;
assets/fabrica.js +6 −6
@@ -56,12 +56,12 @@
56 });56 });
57 });57 });
5858
59 // ---- Auto-submitting selects ----59 // ---- Auto-submitting controls ----
60 // A <select data-autosubmit> submits its form on change; without JS a noscript60 // 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 });
6767
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.
410fn 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}
510539
511/// New-issue form fields + a query flag for `is_pull`.
512#[derive(Debug, Deserialize)]
513pub 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)]
527pub struct CreateQuery {542pub struct CreateQuery {
@@ -531,6 +546,9 @@ pub struct CreateQuery {
531}546}
532547
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.
534pub async fn create(552pub 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 = state593 let issue = state
565 .store594 .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]
729async 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}
709780
710#[tokio::test]781#[tokio::test]