forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.6 KiB
1//! Page: milestones (`/{owner}/{repo}/milestones?state=open|closed`). Each row shows the title, due
2//! date (flagged "overdue" when open and past due in the viewer's timezone), the description
3//! rendered as Markdown, and a progress bar with open and closed issue counts that link to the
4//! filtered issue list. Triage holders get a create form, edit pages and delete forms.
5
6use forge_core::issues::IssueState;
7use jiff::Timestamp;
8use jiff::tz::TimeZone;
9use serde::Deserialize;
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::router::content::Form;
13use topcoat::router::error::{forbidden, not_found, see_other};
14use topcoat::router::{page, path_param, query_params};
15use topcoat::view::{Unescaped, View, component, view};
16
17use crate::auth::require_user;
18use crate::i18n::issues::{self as g, K};
19use crate::i18n::{self, Lang};
20use crate::pages::issue_meta::{
21 RepoFrame, action_error_at, can_triage, issue_subnav, refusal_message, triage_guard,
22};
23use crate::pages::repo::repo_head;
24use crate::pages::repo_ctx::load_repo;
25use crate::render::markdown::render_markdown;
26use crate::state::AppState;
27use crate::views::{enc, shell};
28
29path_param!(owner);
30path_param!(repo);
31path_param!(milestone_id);
32
33#[query_params(error = bad_request)]
34struct MilestoneQuery {
35 state: Option<String>,
36}
37
38/// The create form, and the edit form (which also sends `state`).
39#[derive(Deserialize)]
40struct 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/// The body of the delete form, which only carries the CSRF token.
54#[derive(Deserialize)]
55struct CsrfForm {
56 #[serde(default)]
57 csrf: String,
58}
59
60/// One milestone as the list shows it.
61struct 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/// Today's date (`YYYY-MM-DD`) in the viewer's timezone, UTC for anonymous viewers.
75fn 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/// `/{owner}/{repo}/milestones` for a repo title (`owner/name`).
84fn milestones_href(title: &str) -> String {
85 format!("/{title}/milestones")
86}
87
88/// Turns the form's optional date into the store's `Option`.
89fn due_on_field(raw: &str) -> Option<&str> {
90 let trimmed = raw.trim();
91 (!trimmed.is_empty()).then_some(trimmed)
92}
93
94/// The Open and Closed filter links, with the current one marked.
95#[component]
96async 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/// One row: title, due date, description, progress and the triage controls.
118#[component]
119async 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/// The create form for triage holders.
164#[component]
165async 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/// Gets the milestone list for one state (open by default).
192#[page("/{owner}/{repo}/milestones")]
193async 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/// The header, state tabs, milestone rows and the create form.
243#[component]
244async 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/// Creates a milestone with an optional due date.
282#[page(POST "/{owner}/{repo}/milestones")]
283async 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/// The edit page: title, description, due date and state. Triage holders only.
312#[page("/{owner}/{repo}/milestones/{milestone_id}/edit")]
313async 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/// The edit form body.
343#[component]
344async 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/// Saves the edit form. A state of `closed` closes the milestone; anything else reopens it.
392#[page(POST "/{owner}/{repo}/milestones/{milestone_id}/edit")]
393async 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/// Deletes a milestone. Its issues stay open or closed; they just lose the milestone.
430#[page(POST "/{owner}/{repo}/milestones/{milestone_id}/delete")]
431async 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}