anvilsign in

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
233233 }
234234
235235 /// 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> {
242244 let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
243245 if title.is_empty() {
244246 return None;
⋯ 36 unchanged lines
281283 }
282284 let start = start?;
283285
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+ }
285296
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+ };
286303 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);
300305 Some(out.join("\n"))
301306 }
302307
⋯ 198 unchanged lines
501506
502507 #[test]
503508 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();
505510 // Lands as a nested heading after the section's last non-blank line,
506511 // padded by a blank line, before the next column heading.
507512 assert!(out.contains("- [x] finished task\n\n## new ticket\n\n# Done"));
⋯ 2 unchanged lines
510515 }
511516
512517 #[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]
513529 fn add_task_ticket_level_tracks_the_column_level() {
514530 // Columns at `##` ⇒ tickets nest at `###`.
515531 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();
517533 assert!(out.contains("body\n\n### New one"));
518534 }
519535
520536 #[test]
521537 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();
523539 assert!(out.contains("## tidy up"));
524540 assert!(!out.contains("- [ ] tidy up") && !out.contains("- [x] tidy up"));
525541 // Parses as a ticket under the done column, so it reads as done.
⋯ 9 unchanged lines
535551 fn add_task_into_empty_section_inserts_blank_then_ticket() {
536552 let doc = "# Now\n# Done\n";
537553 assert_eq!(
538- add_task(doc, "Now", "first").unwrap(),
554+ add_task(doc, "Now", "first", "").unwrap(),
539555 "# Now\n\n## first\n# Done\n"
540556 );
541557 }
542558
543559 #[test]
544560 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());
548564 }
549565
550566 #[test]
551567 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();
553569 let board = render_board(&out).expect("has tasks").into_string();
554570 assert!(board.contains("Wire uploads"));
555571 }
⋯ 8 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+20 −4
⋯ 1413 unchanged lines
14141414 expected_tip: String,
14151415 section: String,
14161416 title: String,
1417+ #[serde(default)]
1418+ body: String,
14171419 }
14181420
14191421 /// `GET /{owner}/{repo}/add-task/{rev}/{*path}` — structured "add a task" form
⋯ 27 unchanged lines
14471449 &path,
14481450 &sections,
14491451 "",
1452+ "",
14501453 &tip,
14511454 None,
14521455 user.as_ref(),
⋯ 29 unchanged lines
14821485 let text = String::from_utf8_lossy(&bytes);
14831486 let sections = todomd::task_sections(&text);
14841487
1488+ // Browsers serialize textarea newlines as CRLF; store LF.
1489+ let body = form.body.replace("\r\n", "\n");
1490+
14851491 let render_err = |msg: &str, csrf: &Csrf| {
14861492 add_task_page(
14871493 &owner,
⋯ 2 unchanged lines
14901496 &path,
14911497 &sections,
14921498 &form.title,
1499+ &body,
14931500 &form.expected_tip,
14941501 Some(msg),
14951502 Some(&user),
⋯ 2 unchanged lines
14981505 .into_response()
14991506 };
15001507
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 {
15021509 return render_err(
15031510 "Couldn't add the task — check the title isn't empty and the section exists.",
15041511 &csrf,
⋯ 18 unchanged lines
15231530 }
15241531 }
15251532
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).
15271535 #[allow(clippy::too_many_arguments)]
15281536 fn add_task_page(
15291537 owner: &str,
⋯ 2 unchanged lines
15321540 path: &str,
15331541 sections: &[String],
15341542 title: &str,
1543+ body: &str,
15351544 expected_tip: &str,
15361545 error: Option<&str>,
15371546 user: Option<&User>,
⋯ 1 unchanged line
15391548 ) -> Markup {
15401549 let action = format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(rev));
15411550 let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev));
1551+ let upload_url = format!("/{owner}/{repo}/-/attachments");
15421552 layout(
15431553 &format!("Add task · {path}"),
15441554 user,
⋯ 10 unchanged lines
15551565 @for s in sections { option value=(s) { (s) } }
15561566 }
15571567 } }
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) }
15601575 } }
15611576 p {
15621577 button.btn type="submit" { "Add task" }
⋯ 1 unchanged line
15641579 a.btn.btn-secondary href=(cancel) { "Cancel" }
15651580 }
15661581 }
1582+ script { (PreEscaped(EDITOR_JS)) }
15671583 },
15681584 )
15691585 }
⋯ 758 unchanged lines