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 {
176176 color: var(--fb-fg);
177177 white-space: nowrap;
178178 }
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+}
179204 .menu-item:hover {
180205 background: var(--fb-bg-inset);
181206 text-decoration: none;
@@ -742,16 +767,25 @@ body.drawer-open .drawer-backdrop {
742767 /* Issue/PR dependency lists in the sidebar. */
743768 .dep-list {
744769 list-style: none;
770+ padding: 0;
745771 display: flex;
746772 flex-direction: column;
747- gap: 0.35rem;
748- margin-bottom: 0.5rem;
773+ gap: 0.25rem;
774+ margin: 0 0 0.75rem;
749775 }
750776 .dep-row {
751777 display: flex;
752778 align-items: center;
753- gap: 0.4rem;
779+ gap: 0.45rem;
754780 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;
755789 }
756790 .dep-row > a {
757791 flex: 1;
@@ -761,14 +795,23 @@ body.drawer-open .drawer-backdrop {
761795 text-overflow: ellipsis;
762796 white-space: nowrap;
763797 }
798+.dep-row > a:hover {
799+ text-decoration: none;
800+ color: var(--fb-accent);
801+}
764802 .dep-row .icon-btn {
765- width: 1.5rem;
766- height: 1.5rem;
803+ width: 1.4rem;
804+ height: 1.4rem;
767805 flex: none;
806+ color: var(--fb-fg-muted);
807+}
808+.dep-row .icon-btn:hover {
809+ color: var(--fb-danger);
768810 }
769811 .dep-add {
770812 display: flex;
771813 gap: 0.4rem;
814+ margin-top: 0.25rem;
772815 }
773816 .dep-add input {
774817 flex: 1;
@@ -909,6 +952,7 @@ input[type="password"],
909952 input[type="email"],
910953 input[type="search"],
911954 input[type="url"],
955+input[type="number"],
912956 textarea,
913957 select {
914958 width: 100%;
assets/fabrica.js +6 −6
@@ -56,12 +56,12 @@
5656 });
5757 });
5858
59- // ---- Auto-submitting selects ----
60- // A <select data-autosubmit> submits its form on change; without JS a noscript
61- // Apply button does the same.
62- document.querySelectorAll("select[data-autosubmit]").forEach(function (sel) {
63- sel.addEventListener("change", function () {
64- if (sel.form) sel.form.submit();
59+ // ---- Auto-submitting controls ----
60+ // Any [data-autosubmit] control (select or checkbox) submits its form on
61+ // change; without JS a noscript button does the same.
62+ document.querySelectorAll("[data-autosubmit]").forEach(function (el) {
63+ el.addEventListener("change", function () {
64+ if (el.form) el.form.submit();
6565 });
6666 });
6767
crates/web/src/issues.rs +74 −32
@@ -172,6 +172,7 @@ pub(crate) async fn new_form(
172172 return Ok(Redirect::to("/login").into_response());
173173 }
174174 let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
175+ let repo_labels = state.store.list_labels(&ctx.repo.id).await?;
175176 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
176177 let body = html! {
177178 (repo_header(state, &ctx, kind.tab, &ctx.repo.default_branch, &branches))
@@ -183,6 +184,10 @@ pub(crate) async fn new_form(
183184 input type="text" id="title" name="title" required autofocus;
184185 label for="body" { "Description" }
185186 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+ }
186191 button class="btn btn-primary" type="submit" { "Create " (kind.noun) }
187192 }
188193 }
@@ -250,17 +255,19 @@ pub(crate) async fn view(
250255 @for (c, cauthor) in &comment_rows {
251256 (comment_card(cauthor, &c.body, c.created_at))
252257 }
253- @if can_write {
258+ @if can_write || is_author {
259+ @let comment_form = format!("comment-{}", issue.id);
254260 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)) {
256265 input type="hidden" name="_csrf" value=(csrf);
257266 textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {}
258- div class="comment-actions" {
259- @if can_write || is_author {
260- (state_button(&issue, &csrf, kind))
261- }
262- button class="btn btn-primary" type="submit" { "Comment" }
263- }
267+ }
268+ div class="comment-actions" {
269+ (state_button(&issue, &csrf, kind))
270+ button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" }
264271 }
265272 }
266273 }
@@ -339,7 +346,7 @@ pub(crate) fn state_button(issue: &Issue, csrf: &str, kind: Kind) -> Markup {
339346 form method="post" action=(format!("/issue/{}/state", issue.id)) class="inline-form" {
340347 input type="hidden" name="_csrf" value=(csrf);
341348 input type="hidden" name="state" value=(target);
342- button class="btn" type="submit" { (label) }
349+ button class="btn inline-btn" type="submit" { (label) }
343350 }
344351 }
345352 }
@@ -367,7 +374,7 @@ pub(crate) async fn assignee_form(
367374 option value=(id) selected[issue.assignee_id.as_deref() == Some(id.as_str())] { (name) }
368375 }
369376 }
370- button class="btn" type="submit" { "Set" }
377+ button class="btn inline-btn" type="submit" { "Set" }
371378 }
372379 }
373380 }
@@ -381,16 +388,38 @@ pub(crate) fn label_form(
381388 ) -> Markup {
382389 let is_on = |id: &str| applied.iter().any(|l| l.id == id);
383390 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.
384393 form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" {
385394 input type="hidden" name="_csrf" value=(csrf);
386395 @for l in repo_labels {
387396 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;
389399 " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}
390400 " " (l.name)
391401 }
392402 }
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+ }
394423 }
395424 }
396425 }
@@ -418,7 +447,7 @@ pub(crate) fn deps_section(
418447 form method="post" action=(format!("/issue/{}/deps/remove", issue.id)) class="dep-remove" {
419448 input type="hidden" name="_csrf" value=(csrf);
420449 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)) }
422451 }
423452 }
424453 }
@@ -436,7 +465,7 @@ pub(crate) fn deps_section(
436465 form method="post" action=(format!("/issue/{}/deps/add", issue.id)) class="side-form dep-add" {
437466 input type="hidden" name="_csrf" value=(csrf);
438467 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" }
440469 }
441470 }
442471 @if !dependents.is_empty() {
@@ -508,20 +537,6 @@ pub(crate) async fn issue_url(state: &AppState, repo: &model::Repo, issue: &Issu
508537 format!("/{owner}/{}/-/{seg}/{}", repo.path, issue.number)
509538 }
510539
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-
525540 /// `?is_pull=` query for the create endpoint.
526541 #[derive(Debug, Default, Deserialize)]
527542 pub struct CreateQuery {
@@ -531,6 +546,9 @@ pub struct CreateQuery {
531546 }
532547
533548 /// `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.
534552 pub async fn create(
535553 State(state): State<AppState>,
536554 MaybeUser(viewer): MaybeUser,
@@ -538,9 +556,20 @@ pub async fn create(
538556 headers: HeaderMap,
539557 Path(repo_id): Path<String>,
540558 Query(q): Query<CreateQuery>,
541- Form(form): Form<NewIssueForm>,
559+ body: axum::body::Bytes,
542560 ) -> 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() {
544573 return AppError::Forbidden.into_response();
545574 }
546575 let result = async {
@@ -557,14 +586,27 @@ pub async fn create(
557586 if access_for(&state, Some(&user), &repo).await? < auth::Access::Read {
558587 return Err(AppError::NotFound);
559588 }
560- let title = form.title.trim();
589+ let title = title.trim();
561590 if title.is_empty() {
562591 return Err(AppError::BadRequest("A title is required.".to_string()));
563592 }
564593 let issue = state
565594 .store
566- .create_issue(&repo.id, &user.id, title, form.body.trim(), false)
595+ .create_issue(&repo.id, &user.id, title, desc.trim(), false)
567596 .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+ }
568610 Ok::<String, AppError>(issue_url(&state, &repo, &issue).await)
569611 }
570612 .await;
crates/web/src/pulls.rs +7 −8
@@ -154,17 +154,16 @@ pub(crate) async fn view(
154154 (comment_card(cauthor, &c.body, c.created_at))
155155 }
156156 (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);
158159 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)) {
160161 input type="hidden" name="_csrf" value=(csrf);
161162 textarea name="body" rows="4" placeholder="Leave a comment (markdown)" required {}
162- div class="comment-actions" {
163- @if can_write || is_author {
164- (state_button(&issue, &csrf, Kind::pulls()))
165- }
166- button class="btn btn-primary" type="submit" { "Comment" }
167- }
163+ }
164+ div class="comment-actions" {
165+ (state_button(&issue, &csrf, Kind::pulls()))
166+ button class="btn btn-primary inline-btn" type="submit" form=(comment_form) { "Comment" }
168167 }
169168 }
170169 }
crates/web/src/tests.rs +73 −2
@@ -697,14 +697,85 @@ async fn issues_open_create_and_view() {
697697 // View it.
698698 let req = Request::builder()
699699 .uri("/ada/proj/-/issues/1")
700- .header(header::COOKIE, cookie)
700+ .header(header::COOKIE, cookie.clone())
701701 .body(Body::empty())
702702 .unwrap();
703- let res = app.oneshot(req).await.unwrap();
703+ let res = app.clone().oneshot(req).await.unwrap();
704704 assert_eq!(res.status(), StatusCode::OK);
705705 let html = body_string(res).await;
706706 assert!(html.contains("First bug"), "issue title shown");
707707 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");
708779 }
709780
710781 #[tokio::test]