| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | use forge_core::issues::IssueState; |
| 7 | use jiff::Timestamp; |
| 8 | use jiff::tz::TimeZone; |
| 9 | use serde::Deserialize; |
| 10 | use topcoat::Result; |
| 11 | use topcoat::context::{Cx, app_context}; |
| 12 | use topcoat::router::content::Form; |
| 13 | use topcoat::router::error::{forbidden, not_found, see_other}; |
| 14 | use topcoat::router::{page, path_param, query_params}; |
| 15 | use topcoat::view::{Unescaped, View, component, view}; |
| 16 | |
| 17 | use crate::auth::require_user; |
| 18 | use crate::i18n::issues::{self as g, K}; |
| 19 | use crate::i18n::{self, Lang}; |
| 20 | use crate::pages::issue_meta::{ |
| 21 | RepoFrame, action_error_at, can_triage, issue_subnav, refusal_message, triage_guard, |
| 22 | }; |
| 23 | use crate::pages::repo::repo_head; |
| 24 | use crate::pages::repo_ctx::load_repo; |
| 25 | use crate::render::markdown::render_markdown; |
| 26 | use crate::state::AppState; |
| 27 | use crate::views::{enc, shell}; |
| 28 | |
| 29 | path_param!(owner); |
| 30 | path_param!(repo); |
| 31 | path_param!(milestone_id); |
| 32 | |
| 33 | #[query_params(error = bad_request)] |
| 34 | struct MilestoneQuery { |
| 35 | state: Option<String>, |
| 36 | } |
| 37 | |
| 38 | |
| 39 | #[derive(Deserialize)] |
| 40 | struct MilestoneForm { |
| 41 | #[serde(default)] |
| 42 | csrf: String, |
| 43 | #[serde(default)] |
| 44 | title: String, |
| 45 | #[serde(default)] |
| 46 | description: String, |
| 47 | #[serde(default)] |
| 48 | due_on: String, |
| 49 | #[serde(default)] |
| 50 | state: String, |
| 51 | } |
| 52 | |
| 53 | |
| 54 | #[derive(Deserialize)] |
| 55 | struct CsrfForm { |
| 56 | #[serde(default)] |
| 57 | csrf: String, |
| 58 | } |
| 59 | |
| 60 | |
| 61 | struct MilestoneRow { |
| 62 | title: String, |
| 63 | description_html: String, |
| 64 | due: Option<String>, |
| 65 | overdue: bool, |
| 66 | open: i64, |
| 67 | closed: i64, |
| 68 | open_href: String, |
| 69 | closed_href: String, |
| 70 | edit_href: String, |
| 71 | delete_action: String, |
| 72 | } |
| 73 | |
| 74 | |
| 75 | fn today_in(tz: &str) -> String { |
| 76 | let now = Timestamp::now(); |
| 77 | let zoned = now |
| 78 | .in_tz(tz) |
| 79 | .unwrap_or_else(|_| now.to_zoned(TimeZone::UTC)); |
| 80 | zoned.date().to_string() |
| 81 | } |
| 82 | |
| 83 | |
| 84 | fn milestones_href(title: &str) -> String { |
| 85 | format!("/{title}/milestones") |
| 86 | } |
| 87 | |
| 88 | |
| 89 | fn due_on_field(raw: &str) -> Option<&str> { |
| 90 | let trimmed = raw.trim(); |
| 91 | (!trimmed.is_empty()).then_some(trimmed) |
| 92 | } |
| 93 | |
| 94 | |
| 95 | #[component] |
| 96 | async fn state_tabs(lang: Lang, title: String, closed: bool) -> Result<impl View> { |
| 97 | let open_href = format!("{}?state=open", milestones_href(&title)); |
| 98 | let closed_href = format!("{}?state=closed", milestones_href(&title)); |
| 99 | let open_class = if closed { |
| 100 | "subnav-link" |
| 101 | } else { |
| 102 | "subnav-link current" |
| 103 | }; |
| 104 | let closed_class = if closed { |
| 105 | "subnav-link current" |
| 106 | } else { |
| 107 | "subnav-link" |
| 108 | }; |
| 109 | Ok(view! { |
| 110 | <nav class="subnav" aria-label=(g::t(lang, K::MilestoneState))> |
| 111 | <a class=(open_class) href=(open_href.as_str())>(g::t(lang, K::Open))</a> |
| 112 | <a class=(closed_class) href=(closed_href.as_str())>(g::t(lang, K::Closed))</a> |
| 113 | </nav> |
| 114 | }) |
| 115 | } |
| 116 | |
| 117 | |
| 118 | #[component] |
| 119 | async fn milestone_row( |
| 120 | lang: Lang, |
| 121 | row: MilestoneRow, |
| 122 | csrf: String, |
| 123 | can_triage: bool, |
| 124 | ) -> Result<impl View> { |
| 125 | let total = row.open + row.closed; |
| 126 | let max = total.max(1); |
| 127 | let open_text = g::open_milestone_count(lang, row.open); |
| 128 | let closed_text = g::closed_milestone_count(lang, row.closed); |
| 129 | Ok(view! { |
| 130 | <li class="milestone-row"> |
| 131 | <h3><a href=(row.edit_href.as_str())>(row.title.as_str())</a></h3> |
| 132 | <p class="muted small"> |
| 133 | if let Some(due) = row.due.as_deref() { |
| 134 | (g::due_text(lang, due)) |
| 135 | if row.overdue { |
| 136 | " " <span class="badge overdue">(g::t(lang, K::Overdue))</span> |
| 137 | } |
| 138 | } else { |
| 139 | (g::t(lang, K::NoDueDate)) |
| 140 | } |
| 141 | </p> |
| 142 | if !row.description_html.is_empty() { |
| 143 | <div class="markdown-body">(Unescaped::new_unchecked(row.description_html.clone()))</div> |
| 144 | } |
| 145 | <div class="milestone-progress"> |
| 146 | <progress value=(row.closed) max=(max)></progress> |
| 147 | <span class="small"> |
| 148 | <a href=(row.open_href.as_str())>(open_text.as_str())</a> |
| 149 | " \u{b7} " |
| 150 | <a href=(row.closed_href.as_str())>(closed_text.as_str())</a> |
| 151 | </span> |
| 152 | </div> |
| 153 | if can_triage { |
| 154 | <form method="post" action=(row.delete_action.as_str()) class="remove-form"> |
| 155 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 156 | <button type="submit" class="danger">(g::t(lang, K::Delete))</button> |
| 157 | </form> |
| 158 | } |
| 159 | </li> |
| 160 | }) |
| 161 | } |
| 162 | |
| 163 | |
| 164 | #[component] |
| 165 | async fn milestone_create_form(lang: Lang, csrf: String, action: String) -> Result<impl View> { |
| 166 | Ok(view! { |
| 167 | <section class="card"> |
| 168 | <h2>(g::t(lang, K::NewMilestone))</h2> |
| 169 | <form method="post" action=(action.as_str()) class="stack"> |
| 170 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 171 | <label> |
| 172 | <span>(g::t(lang, K::Title))</span> |
| 173 | <input type="text" name="title" autocomplete="off" required=(true) maxlength="256"> |
| 174 | </label> |
| 175 | <label> |
| 176 | <span>(g::t(lang, K::Description))</span> |
| 177 | <textarea name="description" rows="4"></textarea> |
| 178 | </label> |
| 179 | <label> |
| 180 | <span>(g::t(lang, K::DueDate))</span> |
| 181 | <input type="date" name="due_on"> |
| 182 | </label> |
| 183 | <div> |
| 184 | <button type="submit" class="primary">(g::t(lang, K::CreateMilestone))</button> |
| 185 | </div> |
| 186 | </form> |
| 187 | </section> |
| 188 | }) |
| 189 | } |
| 190 | |
| 191 | |
| 192 | #[page("/{owner}/{repo}/milestones")] |
| 193 | async fn milestones_page(cx: &Cx) -> Result<impl View> { |
| 194 | let owner = path_param::<Owner>(cx); |
| 195 | let name = path_param::<Repo>(cx); |
| 196 | let ctx = load_repo(cx, owner, name).await?; |
| 197 | let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); |
| 198 | let closed = query_params::<MilestoneQuery>(cx)?.state.as_deref() == Some("closed"); |
| 199 | let state = if closed { |
| 200 | IssueState::Closed |
| 201 | } else { |
| 202 | IssueState::Open |
| 203 | }; |
| 204 | let milestones = app_context::<AppState>(cx) |
| 205 | .store |
| 206 | .list_milestones(&ctx.repo.id, Some(state)) |
| 207 | .await?; |
| 208 | let frame = RepoFrame::new(&ctx, app_context::<AppState>(cx), "issues").await?; |
| 209 | let today = today_in(ctx.viewer_tz()); |
| 210 | let title = frame.title.clone(); |
| 211 | let rows: Vec<MilestoneRow> = milestones |
| 212 | .into_iter() |
| 213 | .map(|m| { |
| 214 | let overdue = m.state == IssueState::Open |
| 215 | && m.due_on.as_deref().is_some_and(|due| due < today.as_str()); |
| 216 | MilestoneRow { |
| 217 | description_html: render_markdown(&m.description, None), |
| 218 | overdue, |
| 219 | open_href: format!("/{title}/issues?milestone={}&state=open", enc(&m.title)), |
| 220 | closed_href: format!("/{title}/issues?milestone={}&state=closed", enc(&m.title)), |
| 221 | edit_href: format!("{}/{}/edit", milestones_href(&title), m.id), |
| 222 | delete_action: format!("{}/{}/delete", milestones_href(&title), m.id), |
| 223 | title: m.title, |
| 224 | due: m.due_on, |
| 225 | open: m.open_issues, |
| 226 | closed: m.closed_issues, |
| 227 | } |
| 228 | }) |
| 229 | .collect(); |
| 230 | let empty = rows.is_empty(); |
| 231 | let page_title = g::milestones_title(lang, &frame.title); |
| 232 | let csrf = frame.csrf.clone(); |
| 233 | let can_create = frame.can_triage; |
| 234 | let create_action = milestones_href(&frame.title); |
| 235 | Ok(view! { |
| 236 | shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 237 | milestones_body(lang: lang, frame: frame, rows: rows, empty: empty, closed: closed, |
| 238 | csrf: csrf, can_create: can_create, create_action: create_action)) |
| 239 | }) |
| 240 | } |
| 241 | |
| 242 | |
| 243 | #[component] |
| 244 | async fn milestones_body( |
| 245 | lang: Lang, |
| 246 | frame: RepoFrame, |
| 247 | rows: Vec<MilestoneRow>, |
| 248 | empty: bool, |
| 249 | closed: bool, |
| 250 | csrf: String, |
| 251 | can_create: bool, |
| 252 | create_action: String, |
| 253 | ) -> Result<impl View> { |
| 254 | let title = frame.title.clone(); |
| 255 | let can_triage = frame.can_triage; |
| 256 | let row_csrf = csrf.clone(); |
| 257 | let header = frame.head.clone(); |
| 258 | Ok(view! { |
| 259 | repo_head(head: header) |
| 260 | issue_subnav(lang: lang, title: title.clone(), active: "milestones".to_owned()) |
| 261 | if frame.archived { |
| 262 | <section class="card notice" role="status">(g::t(lang, K::MilestonesArchived))</section> |
| 263 | } |
| 264 | state_tabs(lang: lang, title: title.clone(), closed: closed) |
| 265 | <section class="card"> |
| 266 | if empty { |
| 267 | <p class="muted">(if closed { g::t(lang, K::NoClosedMilestones) } else { g::t(lang, K::NoOpenMilestones) })</p> |
| 268 | } |
| 269 | <ul class="milestone-list"> |
| 270 | for row in rows.into_iter() { |
| 271 | milestone_row(lang: lang, row: row, csrf: row_csrf.clone(), can_triage: can_triage) |
| 272 | } |
| 273 | </ul> |
| 274 | </section> |
| 275 | if can_create { |
| 276 | milestone_create_form(lang: lang, csrf: csrf.clone(), action: create_action) |
| 277 | } |
| 278 | }) |
| 279 | } |
| 280 | |
| 281 | |
| 282 | #[page(POST "/{owner}/{repo}/milestones")] |
| 283 | async fn milestone_create(cx: &Cx, Form(form): Form<MilestoneForm>) -> Result<impl View> { |
| 284 | let owner = path_param::<Owner>(cx); |
| 285 | let name = path_param::<Repo>(cx); |
| 286 | let ctx = load_repo(cx, owner, name).await?; |
| 287 | triage_guard(cx, &ctx, &form.csrf).await?; |
| 288 | let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); |
| 289 | let back = milestones_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); |
| 290 | let result = app_context::<AppState>(cx) |
| 291 | .store |
| 292 | .create_milestone( |
| 293 | &ctx.repo.id, |
| 294 | &form.title, |
| 295 | &form.description, |
| 296 | due_on_field(&form.due_on), |
| 297 | ) |
| 298 | .await; |
| 299 | match result { |
| 300 | Ok(_) => Err(see_other(back).into()), |
| 301 | Err(e) => { |
| 302 | let message = refusal_message(e)?; |
| 303 | Ok(view! { |
| 304 | shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), |
| 305 | action_error_at(lang: lang, message: message, back: back)) |
| 306 | }) |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | |
| 312 | #[page("/{owner}/{repo}/milestones/{milestone_id}/edit")] |
| 313 | async fn milestone_edit_page(cx: &Cx) -> Result<impl View> { |
| 314 | let owner = path_param::<Owner>(cx); |
| 315 | let name = path_param::<Repo>(cx); |
| 316 | let id = path_param::<MilestoneId>(cx); |
| 317 | let ctx = load_repo(cx, owner, name).await?; |
| 318 | let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); |
| 319 | require_user(cx).await?; |
| 320 | if !can_triage(&ctx) { |
| 321 | return Err(forbidden().into()); |
| 322 | } |
| 323 | let Some(milestone) = app_context::<AppState>(cx) |
| 324 | .store |
| 325 | .milestone_by_id(&ctx.repo.id, id) |
| 326 | .await? |
| 327 | else { |
| 328 | return Err(not_found().into()); |
| 329 | }; |
| 330 | let frame = RepoFrame::new(&ctx, app_context::<AppState>(cx), "issues").await?; |
| 331 | let page_title = g::milestone_edit_title(lang, &frame.title); |
| 332 | let action = format!("{}/{}/edit", milestones_href(&frame.title), milestone.id); |
| 333 | let due = milestone.due_on.clone().unwrap_or_default(); |
| 334 | let is_open = milestone.state == IssueState::Open; |
| 335 | Ok(view! { |
| 336 | shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 337 | milestone_edit_body(lang: lang, frame: frame, action: action, title_value: milestone.title, |
| 338 | description: milestone.description, due: due, is_open: is_open)) |
| 339 | }) |
| 340 | } |
| 341 | |
| 342 | |
| 343 | #[component] |
| 344 | async fn milestone_edit_body( |
| 345 | lang: Lang, |
| 346 | frame: RepoFrame, |
| 347 | action: String, |
| 348 | title_value: String, |
| 349 | description: String, |
| 350 | due: String, |
| 351 | is_open: bool, |
| 352 | ) -> Result<impl View> { |
| 353 | let back = milestones_href(&frame.title); |
| 354 | let csrf = frame.csrf.clone(); |
| 355 | let header = frame.head.clone(); |
| 356 | Ok(view! { |
| 357 | repo_head(head: header) |
| 358 | issue_subnav(lang: lang, title: frame.title.clone(), active: "milestones".to_owned()) |
| 359 | <section class="card"> |
| 360 | <h2>(g::t(lang, K::EditMilestone))</h2> |
| 361 | <p><a href=(back.as_str())>(g::t(lang, K::BackToMilestones))</a></p> |
| 362 | <form method="post" action=(action.as_str()) class="stack"> |
| 363 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 364 | <label> |
| 365 | <span>(g::t(lang, K::Title))</span> |
| 366 | <input type="text" name="title" value=(title_value.as_str()) autocomplete="off" required=(true) maxlength="256"> |
| 367 | </label> |
| 368 | <label> |
| 369 | <span>(g::t(lang, K::Description))</span> |
| 370 | <textarea name="description" rows="4">(description.as_str())</textarea> |
| 371 | </label> |
| 372 | <label> |
| 373 | <span>(g::t(lang, K::DueDate))</span> |
| 374 | <input type="date" name="due_on" value=(due.as_str())> |
| 375 | </label> |
| 376 | <label> |
| 377 | <span>(g::t(lang, K::StateLabel))</span> |
| 378 | <select name="state"> |
| 379 | <option value="open" selected=(is_open)>(g::t(lang, K::Open))</option> |
| 380 | <option value="closed" selected=(!is_open)>(g::t(lang, K::Closed))</option> |
| 381 | </select> |
| 382 | </label> |
| 383 | <div> |
| 384 | <button type="submit">(g::t(lang, K::SaveMilestone))</button> |
| 385 | </div> |
| 386 | </form> |
| 387 | </section> |
| 388 | }) |
| 389 | } |
| 390 | |
| 391 | |
| 392 | #[page(POST "/{owner}/{repo}/milestones/{milestone_id}/edit")] |
| 393 | async fn milestone_edit_save(cx: &Cx, Form(form): Form<MilestoneForm>) -> Result<impl View> { |
| 394 | let owner = path_param::<Owner>(cx); |
| 395 | let name = path_param::<Repo>(cx); |
| 396 | let id = path_param::<MilestoneId>(cx); |
| 397 | let ctx = load_repo(cx, owner, name).await?; |
| 398 | triage_guard(cx, &ctx, &form.csrf).await?; |
| 399 | let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); |
| 400 | let back = milestones_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); |
| 401 | let state = if form.state == "closed" { |
| 402 | IssueState::Closed |
| 403 | } else { |
| 404 | IssueState::Open |
| 405 | }; |
| 406 | let result = app_context::<AppState>(cx) |
| 407 | .store |
| 408 | .update_milestone( |
| 409 | &ctx.repo.id, |
| 410 | id, |
| 411 | &form.title, |
| 412 | &form.description, |
| 413 | due_on_field(&form.due_on), |
| 414 | state, |
| 415 | ) |
| 416 | .await; |
| 417 | match result { |
| 418 | Ok(()) => Err(see_other(back).into()), |
| 419 | Err(e) => { |
| 420 | let message = refusal_message(e)?; |
| 421 | Ok(view! { |
| 422 | shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), |
| 423 | action_error_at(lang: lang, message: message, back: back)) |
| 424 | }) |
| 425 | } |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | |
| 430 | #[page(POST "/{owner}/{repo}/milestones/{milestone_id}/delete")] |
| 431 | async fn milestone_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<impl View> { |
| 432 | let owner = path_param::<Owner>(cx); |
| 433 | let name = path_param::<Repo>(cx); |
| 434 | let id = path_param::<MilestoneId>(cx); |
| 435 | let ctx = load_repo(cx, owner, name).await?; |
| 436 | triage_guard(cx, &ctx, &form.csrf).await?; |
| 437 | let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(u, _)| u)); |
| 438 | let back = milestones_href(&format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)); |
| 439 | let result = app_context::<AppState>(cx) |
| 440 | .store |
| 441 | .delete_milestone(&ctx.repo.id, id) |
| 442 | .await; |
| 443 | match result { |
| 444 | Ok(()) => Err(see_other(back).into()), |
| 445 | Err(e) => { |
| 446 | let message = refusal_message(e)?; |
| 447 | Ok(view! { |
| 448 | shell(title: g::t(lang, K::CouldNotSave), viewer: ctx.viewer.as_ref(), |
| 449 | action_error_at(lang: lang, message: message, back: back)) |
| 450 | }) |
| 451 | } |
| 452 | } |
| 453 | } |