collin/anvil · 1496ee95
feat: add-task gets title + description, with image upload in the description
Collin Richards · 2026-06-10 13:50 UTC · 1496ee952d9307f84ee075ad02a3feea1a185915 · parent 256654bc · browse files
modifiedcrates/anvil-web/src/todomd.rs+44 −28
| ⋯ 232 unchanged lines | |||
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | 235 | /// The **add** operation: append a ticket — a nested heading (`## <title>`, | |
| 236 | - | /// one level below the column headings) — to the end of the section named | |
| 237 | - | /// `section`, touching no other byte of the document (the todo-md round-trip | |
| 238 | - | /// rule). A ticket is the richer card style: it inherits done-ness from its | |
| 239 | - | /// column, so it carries no checkbox. Returns `None` when the title is blank, | |
| 240 | - | /// the document has no column headings to nest under, or no such section exists. | |
| 241 | - | pub fn add_task(text: &str, section: &str, title: &str) -> Option<String> { | |
| 236 | + | /// one level below the column headings) followed by an optional `body` (the | |
| 237 | + | /// ticket's description) — to the end of the section named `section`, touching | |
| 238 | + | /// no other byte of the document (the todo-md round-trip rule). A ticket is the | |
| 239 | + | /// richer card style: it inherits done-ness from its column, so it carries no | |
| 240 | + | /// checkbox. `body` should already be LF-normalized; its surrounding blank | |
| 241 | + | /// lines are trimmed. Returns `None` when the title is blank, the document has | |
| 242 | + | /// no column headings to nest under, or no such section exists. | |
| 243 | + | pub fn add_task(text: &str, section: &str, title: &str, body: &str) -> Option<String> { | |
| 242 | 244 | let title = title.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 243 | 245 | if title.is_empty() { | |
| 244 | 246 | return None; | |
| ⋯ 36 unchanged lines | |||
| 281 | 283 | } | |
| 282 | 284 | let start = start?; | |
| 283 | 285 | ||
| 284 | - | let ticket = format!("{} {title}", "#".repeat(col as usize + 1)); | |
| 286 | + | // The ticket block: a blank line (separating it from prior content or the | |
| 287 | + | // section heading), the heading, then — if there's a description — a blank | |
| 288 | + | // line and the body. | |
| 289 | + | let heading = format!("{} {title}", "#".repeat(col as usize + 1)); | |
| 290 | + | let mut block = vec![String::new(), heading]; | |
| 291 | + | let body = body.trim(); | |
| 292 | + | if !body.is_empty() { | |
| 293 | + | block.push(String::new()); | |
| 294 | + | block.extend(body.split('\n').map(str::to_string)); | |
| 295 | + | } | |
| 285 | 296 | ||
| 297 | + | // Insert after the section's last non-blank line, or right after the | |
| 298 | + | // heading for an empty section. | |
| 299 | + | let pos = match (start..end).rev().find(|&i| !lines[i].trim().is_empty()) { | |
| 300 | + | Some(i) => i + 1, | |
| 301 | + | None => start, | |
| 302 | + | }; | |
| 286 | 303 | let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect(); | |
| 287 | - | match (start..end).rev().find(|&i| !lines[i].trim().is_empty()) { | |
| 288 | - | // After the section's last non-blank line, with a blank line before it | |
| 289 | - | // so the heading stands on its own. | |
| 290 | - | Some(i) => { | |
| 291 | - | out.insert(i + 1, ticket); | |
| 292 | - | out.insert(i + 1, String::new()); | |
| 293 | - | } | |
| 294 | - | // Empty section: a blank line, then the ticket, right after the heading. | |
| 295 | - | None => { | |
| 296 | - | out.insert(start, ticket); | |
| 297 | - | out.insert(start, String::new()); | |
| 298 | - | } | |
| 299 | - | } | |
| 304 | + | out.splice(pos..pos, block); | |
| 300 | 305 | Some(out.join("\n")) | |
| 301 | 306 | } | |
| 302 | 307 | ||
| ⋯ 198 unchanged lines | |||
| 501 | 506 | ||
| 502 | 507 | #[test] | |
| 503 | 508 | fn add_task_appends_ticket_within_section_byte_exactly() { | |
| 504 | - | let out = add_task(DOC, "Now", "new ticket").unwrap(); | |
| 509 | + | let out = add_task(DOC, "Now", "new ticket", "").unwrap(); | |
| 505 | 510 | // Lands as a nested heading after the section's last non-blank line, | |
| 506 | 511 | // padded by a blank line, before the next column heading. | |
| 507 | 512 | assert!(out.contains("- [x] finished task\n\n## new ticket\n\n# Done")); | |
| ⋯ 2 unchanged lines | |||
| 510 | 515 | } | |
| 511 | 516 | ||
| 512 | 517 | #[test] | |
| 518 | + | fn add_task_with_body_writes_heading_then_description() { | |
| 519 | + | let out = add_task("# Now\n# Done\n", "Now", "Title", "Line one\nLine two").unwrap(); | |
| 520 | + | assert_eq!(out, "# Now\n\n## Title\n\nLine one\nLine two\n# Done\n"); | |
| 521 | + | // The body becomes the ticket's parsed details. | |
| 522 | + | let now = parse(&out).into_iter().find(|s| s.title == "Now").unwrap(); | |
| 523 | + | let ticket = now.tasks.iter().find(|t| t.title == "Title").unwrap(); | |
| 524 | + | assert!(ticket.ticket); | |
| 525 | + | assert_eq!(ticket.details.trim(), "Line one\nLine two"); | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | #[test] | |
| 513 | 529 | fn add_task_ticket_level_tracks_the_column_level() { | |
| 514 | 530 | // Columns at `##` ⇒ tickets nest at `###`. | |
| 515 | 531 | let doc = "## Backlog\n\n### Existing\n\nbody\n"; | |
| 516 | - | let out = add_task(doc, "Backlog", "New one").unwrap(); | |
| 532 | + | let out = add_task(doc, "Backlog", "New one", "").unwrap(); | |
| 517 | 533 | assert!(out.contains("body\n\n### New one")); | |
| 518 | 534 | } | |
| 519 | 535 | ||
| 520 | 536 | #[test] | |
| 521 | 537 | fn add_task_to_done_section_inherits_done_no_checkbox() { | |
| 522 | - | let out = add_task(DOC, "Done", "tidy up").unwrap(); | |
| 538 | + | let out = add_task(DOC, "Done", "tidy up", "").unwrap(); | |
| 523 | 539 | assert!(out.contains("## tidy up")); | |
| 524 | 540 | assert!(!out.contains("- [ ] tidy up") && !out.contains("- [x] tidy up")); | |
| 525 | 541 | // Parses as a ticket under the done column, so it reads as done. | |
| ⋯ 9 unchanged lines | |||
| 535 | 551 | fn add_task_into_empty_section_inserts_blank_then_ticket() { | |
| 536 | 552 | let doc = "# Now\n# Done\n"; | |
| 537 | 553 | assert_eq!( | |
| 538 | - | add_task(doc, "Now", "first").unwrap(), | |
| 554 | + | add_task(doc, "Now", "first", "").unwrap(), | |
| 539 | 555 | "# Now\n\n## first\n# Done\n" | |
| 540 | 556 | ); | |
| 541 | 557 | } | |
| 542 | 558 | ||
| 543 | 559 | #[test] | |
| 544 | 560 | fn add_task_rejects_missing_section_blank_title_and_headingless() { | |
| 545 | - | assert!(add_task(DOC, "Nonexistent", "x").is_none()); | |
| 546 | - | assert!(add_task(DOC, "Now", " ").is_none()); | |
| 547 | - | assert!(add_task("no headings here\n", "Whatever", "x").is_none()); | |
| 561 | + | assert!(add_task(DOC, "Nonexistent", "x", "").is_none()); | |
| 562 | + | assert!(add_task(DOC, "Now", " ", "body still rejected").is_none()); | |
| 563 | + | assert!(add_task("no headings here\n", "Whatever", "x", "").is_none()); | |
| 548 | 564 | } | |
| 549 | 565 | ||
| 550 | 566 | #[test] | |
| 551 | 567 | fn added_ticket_renders_as_a_board_card() { | |
| 552 | - | let out = add_task(DOC, "Now", "Wire uploads").unwrap(); | |
| 568 | + | let out = add_task(DOC, "Now", "Wire uploads", "").unwrap(); | |
| 553 | 569 | let board = render_board(&out).expect("has tasks").into_string(); | |
| 554 | 570 | assert!(board.contains("Wire uploads")); | |
| 555 | 571 | } | |
| ⋯ 8 unchanged lines | |||
modifiedcrates/anvil-web/src/ui.rs+20 −4
| ⋯ 1413 unchanged lines | |||
| 1414 | 1414 | expected_tip: String, | |
| 1415 | 1415 | section: String, | |
| 1416 | 1416 | title: String, | |
| 1417 | + | #[serde(default)] | |
| 1418 | + | body: String, | |
| 1417 | 1419 | } | |
| 1418 | 1420 | ||
| 1419 | 1421 | /// `GET /{owner}/{repo}/add-task/{rev}/{*path}` — structured "add a task" form | |
| ⋯ 27 unchanged lines | |||
| 1447 | 1449 | &path, | |
| 1448 | 1450 | §ions, | |
| 1449 | 1451 | "", | |
| 1452 | + | "", | |
| 1450 | 1453 | &tip, | |
| 1451 | 1454 | None, | |
| 1452 | 1455 | user.as_ref(), | |
| ⋯ 29 unchanged lines | |||
| 1482 | 1485 | let text = String::from_utf8_lossy(&bytes); | |
| 1483 | 1486 | let sections = todomd::task_sections(&text); | |
| 1484 | 1487 | ||
| 1488 | + | // Browsers serialize textarea newlines as CRLF; store LF. | |
| 1489 | + | let body = form.body.replace("\r\n", "\n"); | |
| 1490 | + | ||
| 1485 | 1491 | let render_err = |msg: &str, csrf: &Csrf| { | |
| 1486 | 1492 | add_task_page( | |
| 1487 | 1493 | &owner, | |
| ⋯ 2 unchanged lines | |||
| 1490 | 1496 | &path, | |
| 1491 | 1497 | §ions, | |
| 1492 | 1498 | &form.title, | |
| 1499 | + | &body, | |
| 1493 | 1500 | &form.expected_tip, | |
| 1494 | 1501 | Some(msg), | |
| 1495 | 1502 | Some(&user), | |
| ⋯ 2 unchanged lines | |||
| 1498 | 1505 | .into_response() | |
| 1499 | 1506 | }; | |
| 1500 | 1507 | ||
| 1501 | - | let Some(updated) = todomd::add_task(&text, &form.section, &form.title) else { | |
| 1508 | + | let Some(updated) = todomd::add_task(&text, &form.section, &form.title, &body) else { | |
| 1502 | 1509 | return render_err( | |
| 1503 | 1510 | "Couldn't add the task — check the title isn't empty and the section exists.", | |
| 1504 | 1511 | &csrf, | |
| ⋯ 18 unchanged lines | |||
| 1523 | 1530 | } | |
| 1524 | 1531 | } | |
| 1525 | 1532 | ||
| 1526 | - | /// The add-task form: a section dropdown and a title field. | |
| 1533 | + | /// The add-task form: a section dropdown, a title field, and a Markdown | |
| 1534 | + | /// description (which supports paste/drop image upload, like the file editor). | |
| 1527 | 1535 | #[allow(clippy::too_many_arguments)] | |
| 1528 | 1536 | fn add_task_page( | |
| 1529 | 1537 | owner: &str, | |
| ⋯ 2 unchanged lines | |||
| 1532 | 1540 | path: &str, | |
| 1533 | 1541 | sections: &[String], | |
| 1534 | 1542 | title: &str, | |
| 1543 | + | body: &str, | |
| 1535 | 1544 | expected_tip: &str, | |
| 1536 | 1545 | error: Option<&str>, | |
| 1537 | 1546 | user: Option<&User>, | |
| ⋯ 1 unchanged line | |||
| 1539 | 1548 | ) -> Markup { | |
| 1540 | 1549 | let action = format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(rev)); | |
| 1541 | 1550 | let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev)); | |
| 1551 | + | let upload_url = format!("/{owner}/{repo}/-/attachments"); | |
| 1542 | 1552 | layout( | |
| 1543 | 1553 | &format!("Add task · {path}"), | |
| 1544 | 1554 | user, | |
| ⋯ 10 unchanged lines | |||
| 1555 | 1565 | @for s in sections { option value=(s) { (s) } } | |
| 1556 | 1566 | } | |
| 1557 | 1567 | } } | |
| 1558 | - | p { label { "Task" br; | |
| 1559 | - | input type="text" name="title" value=(title) placeholder="Describe the task" autofocus; | |
| 1568 | + | p { label { "Title" br; | |
| 1569 | + | input type="text" name="title" value=(title) placeholder="Short ticket title" autofocus; | |
| 1570 | + | } } | |
| 1571 | + | p { label { "Description" br; | |
| 1572 | + | textarea.editor name="body" rows="10" spellcheck="false" | |
| 1573 | + | placeholder="Markdown — paste or drop an image to attach it" | |
| 1574 | + | data-upload-url=(upload_url) data-csrf=(csrf) { (body) } | |
| 1560 | 1575 | } } | |
| 1561 | 1576 | p { | |
| 1562 | 1577 | button.btn type="submit" { "Add task" } | |
| ⋯ 1 unchanged line | |||
| 1564 | 1579 | a.btn.btn-secondary href=(cancel) { "Cancel" } | |
| 1565 | 1580 | } | |
| 1566 | 1581 | } | |
| 1582 | + | script { (PreEscaped(EDITOR_JS)) } | |
| 1567 | 1583 | }, | |
| 1568 | 1584 | ) | |
| 1569 | 1585 | } | |
| ⋯ 758 unchanged lines | |||