crates/cli/ents-web/src/pages/issues.rs
issues.rshistorycomment on this file
| 1 | //! `GET /issues`, `GET /issues/{id}`, `POST /issues`, |
| 2 | //! `POST /issues/{id}`, `POST /issues/{id}/comment`: the issue surface |
| 3 | //! (`model.issue`), a top-level tab of its own (`crate::pages::Tab::Issues`; |
| 4 | //! see [`super`]'s own doc) rather than an entry in the `meta` tab's |
| 5 | //! registry -- issues are a working surface like comments, not repository |
| 6 | //! metadata. |
| 7 | //! |
| 8 | //! Every read is `ents_forge::issue::{list,show}` and every mutation is |
| 9 | //! `ents_forge::issue::{new,edit}` or `ents_forge::comment::add` -- the web |
| 10 | //! is another caller of the same library funcs (`lens.parity`), never a |
| 11 | //! second issue or thread implementation. An issue's discussion is its |
| 12 | //! thread: the comments naming `issues/<id>` as their context |
| 13 | //! (`model.comment-context`), aggregated by `ents_forge::comment::thread` |
| 14 | //! and rendered through `crate::pages::comments::thread_section`, never a |
| 15 | //! list the issue stores. |
| 16 | |
| 17 | use std::sync::Arc; |
| 18 | |
| 19 | use axum::Form; |
| 20 | use axum::extract::{Path, State}; |
| 21 | use axum::response::{IntoResponse, Redirect}; |
| 22 | use ents_forge::issue::{self, EditIssue, IssueAction, NewIssue}; |
| 23 | use ents_model::MemberId; |
| 24 | use gix_object::{Find, Write}; |
| 25 | use maud::{Markup, html}; |
| 26 | use serde::Deserialize; |
| 27 | |
| 28 | use crate::error::Result; |
| 29 | use crate::session::Session; |
| 30 | use crate::state::AppState; |
| 31 | |
| 32 | /// `GET /issues`: the Issues split (`crate::pages::layout_split`) -- |
| 33 | /// every issue recorded in this repository (`ents_forge::issue::list_all`) |
| 34 | /// as the sidebar, its state/assignees/labels on each row's own locator |
| 35 | /// line, beside the new-issue composer in the pane. |
| 36 | /// |
| 37 | /// # Errors |
| 38 | /// |
| 39 | /// Propagates a ref-store or object read failure. |
| 40 | // @relation(model.issue, scope=function) |
| 41 | pub async fn list<O>( |
| 42 | State(state): State<Arc<AppState<O>>>, |
| 43 | axum::Extension(session): axum::Extension<Session>, |
| 44 | ) -> Result<Markup> |
| 45 | where |
| 46 | O: Find + Write + Send + 'static, |
| 47 | { |
| 48 | let (rows, unreadable) = issue::list_all(state.refs.as_ref(), &*state.objects())?; |
| 49 | let failures: Vec<(String, String)> = unreadable |
| 50 | .into_iter() |
| 51 | .map(|entry| (entry.refname, entry.error)) |
| 52 | .collect(); |
| 53 | let labels = known_labels(&rows); |
| 54 | Ok(super::layout_split( |
| 55 | &super::RepoHeader::from_state(&state), |
| 56 | &super::identity_label(&state), |
| 57 | super::Tab::Issues, |
| 58 | "Issues", |
| 59 | false, |
| 60 | issues_sidebar(&rows, None), |
| 61 | html! { |
| 62 | div.readable { |
| 63 | (crate::render::unreadable_disclosure(&failures)) |
| 64 | @if rows.is_empty() { |
| 65 | (super::blankslate( |
| 66 | "No issues yet", |
| 67 | html! { "Open one with the form below." }, |
| 68 | )) |
| 69 | } |
| 70 | div.card { |
| 71 | div.card-header { "Open an Issue" } |
| 72 | (new_form(&session, &labels)) |
| 73 | } |
| 74 | (super::members_datalist(&state)) |
| 75 | } |
| 76 | }, |
| 77 | )) |
| 78 | } |
| 79 | |
| 80 | /// The Issues split's `.tree` sidebar: a `.tree-head` naming the family and |
| 81 | /// carrying the "+ New" link into the composer, then every issue as a |
| 82 | /// two-line `.side-row` -- its title (`.side-title`), then a `.side-meta` |
| 83 | /// locator of a state-colored `.dot`, its state, assignees, and labels -- |
| 84 | /// linking to its own page, `.active` naming the viewed issue's id. |
| 85 | fn issues_sidebar(rows: &[(String, ents_forge::Issue)], active: Option<&str>) -> Markup { |
| 86 | html! { |
| 87 | (super::tree_head("Issues", "/issues", active.is_some())) |
| 88 | @if rows.is_empty() { |
| 89 | span.tree-note { "No issues yet." } |
| 90 | } |
| 91 | @for (id, issue) in rows { |
| 92 | a.side-row.active[active == Some(id.as_str())] href={ "/issues/" (id) } { |
| 93 | span.side-title { (issue.title) } |
| 94 | span.side-meta { |
| 95 | (state_dot(&issue.state)) |
| 96 | span.locator { |
| 97 | (issue.state) |
| 98 | " \u{b7} " |
| 99 | @if let Some(first) = issue.assignees.first() { |
| 100 | "@" (first.as_str()) |
| 101 | @if issue.assignees.len() > 1 { |
| 102 | " +" (issue.assignees.len() - 1) |
| 103 | } |
| 104 | } @else { |
| 105 | "unassigned" |
| 106 | } |
| 107 | " \u{b7} " |
| 108 | @if issue.labels.is_empty() { "no labels" } @else { (issue.labels.join(", ")) } |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// `GET /issues/{id}`: one issue (`ents_forge::issue::show`), an edit form |
| 117 | /// for its state/assignees/labels, and its discussion thread -- the |
| 118 | /// comments naming `issues/<id>` as their context |
| 119 | /// (`ents_forge::comment::thread`, `model.comment-context`), rendered like |
| 120 | /// every other conversation in this crate. The metadata `dl.entity-view` |
| 121 | /// stays hand-rolled rather than [`crate::render::view`]'s generic dump: |
| 122 | /// every row is a domain widget (state chip, assignee avatars, label |
| 123 | /// chips, an "unassigned"/"none" placeholder), not a field's plain text. |
| 124 | /// |
| 125 | /// # Errors |
| 126 | /// |
| 127 | /// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if |
| 128 | /// `id` has no issue ref at all; an issue ref whose stored tree this |
| 129 | /// build cannot read back degrades to [`crate::render::unreadable`]'s |
| 130 | /// marker card instead of erroring. Otherwise propagates a ref-store or |
| 131 | /// object read failure. |
| 132 | // @relation(model.issue, model.comment-context, scope=function) |
| 133 | pub async fn show<O>( |
| 134 | State(state): State<Arc<AppState<O>>>, |
| 135 | axum::Extension(session): axum::Extension<Session>, |
| 136 | Path(id): Path<String>, |
| 137 | ) -> Result<Markup> |
| 138 | where |
| 139 | O: Find + Write + Send + 'static, |
| 140 | { |
| 141 | let issue = match issue::show(state.refs.as_ref(), &*state.objects(), &id) { |
| 142 | Ok(issue) => issue, |
| 143 | // No ref at all stays a real not-found; any other failure (a tree |
| 144 | // this build's shape cannot read back) is an existing entity this |
| 145 | // page degrades to the plain unreadable card for. |
| 146 | Err(source @ ents_forge::Error::NotFound { .. }) => return Err(source.into()), |
| 147 | Err(source) => { |
| 148 | return Ok(super::layout( |
| 149 | &super::RepoHeader::from_state(&state), |
| 150 | &super::identity_label(&state), |
| 151 | super::Tab::Issues, |
| 152 | &format!("Issue {}", ents_forge::abbreviate_id(&id)), |
| 153 | html! { |
| 154 | (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id))) |
| 155 | div.readable { (crate::render::unreadable(&source.to_string())) } |
| 156 | }, |
| 157 | )); |
| 158 | } |
| 159 | }; |
| 160 | let context = format!("issues/{id}"); |
| 161 | let thread = ents_forge::comment::thread(state.refs.as_ref(), &*state.objects(), &context)?; |
| 162 | let body = |
| 163 | crate::asciidoc::to_html(&issue.body).unwrap_or_else(|_| html! { p { (issue.body) } }); |
| 164 | let return_to = format!("/issues/{id}"); |
| 165 | // Best-effort: the sidebar listing every issue beside this one is |
| 166 | // navigation chrome, never a reason to fail the issue's own page. |
| 167 | let (rows, _unreadable) = |
| 168 | issue::list_all(state.refs.as_ref(), &*state.objects()).unwrap_or_default(); |
| 169 | let labels = known_labels(&rows); |
| 170 | Ok(super::layout_split( |
| 171 | &super::RepoHeader::from_state(&state), |
| 172 | &super::identity_label(&state), |
| 173 | super::Tab::Issues, |
| 174 | &issue.title, |
| 175 | false, |
| 176 | issues_sidebar(&rows, Some(&id)), |
| 177 | html! { |
| 178 | (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id))) |
| 179 | div.readable { |
| 180 | div.card { |
| 181 | h1.commit-subject { (issue.title) } |
| 182 | dl.entity-view { |
| 183 | dt { "state" } |
| 184 | dd { (state_chip(&issue.state)) } |
| 185 | dt { "assignees" } |
| 186 | dd { |
| 187 | @if issue.assignees.is_empty() { |
| 188 | span { "unassigned" } |
| 189 | } @else { |
| 190 | @for assignee in &issue.assignees { |
| 191 | (super::avatar(assignee.as_str())) " @" (assignee.as_str()) " " |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | dt { "labels" } |
| 196 | dd { |
| 197 | @if issue.labels.is_empty() { |
| 198 | span { "none" } |
| 199 | } @else { |
| 200 | @for label in &issue.labels { |
| 201 | span.label-chip { (label) } " " |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | } |
| 206 | div.doc-body { (body) } |
| 207 | } |
| 208 | details.disclosure { |
| 209 | summary { "Edit state, assignees, labels" } |
| 210 | (edit_form(&session, &issue, &labels)) |
| 211 | (super::members_datalist(&state)) |
| 212 | } |
| 213 | h2 { "Discussion" } |
| 214 | @if thread.is_empty() { |
| 215 | (super::blankslate( |
| 216 | "No comments yet", |
| 217 | html! { "Start the discussion below." }, |
| 218 | )) |
| 219 | } @else { |
| 220 | (crate::pages::comments::thread_section(&state, &session, &thread, &return_to)) |
| 221 | } |
| 222 | div.card { |
| 223 | div.card-header { "Add a comment" } |
| 224 | (comment_form(&session, &id)) |
| 225 | } |
| 226 | } |
| 227 | }, |
| 228 | )) |
| 229 | } |
| 230 | |
| 231 | /// `POST /issues`: open an issue at a freshly generated |
| 232 | /// `refs/meta/issues/<id>` (`ents_forge::issue::new`), signed |
| 233 | /// (`roots.web-signing`) on behalf of the current session |
| 234 | /// (`roots.web-session`). The posted fields are |
| 235 | /// [`IssueAction::New`]'s own ([`crate::form::parse_action`]), so the |
| 236 | /// form's shape and this handler's parse are one declaration. |
| 237 | /// |
| 238 | /// # Errors |
| 239 | /// |
| 240 | /// [`crate::Error::BadCsrf`] if the posted token does not match; |
| 241 | /// otherwise propagates [`ents_forge::issue::new`]'s own failures. |
| 242 | // @relation(model.issue, roots.web-signing, roots.web-session, scope=function) |
| 243 | pub async fn create<O>( |
| 244 | State(state): State<Arc<AppState<O>>>, |
| 245 | axum::Extension(session): axum::Extension<Session>, |
| 246 | Form(pairs): Form<Vec<(String, String)>>, |
| 247 | ) -> Result<impl IntoResponse> |
| 248 | where |
| 249 | O: Find + Write + Send + 'static, |
| 250 | { |
| 251 | super::require_csrf(&session, crate::form::posted_csrf(&pairs))?; |
| 252 | dispatch(&state, &session, crate::form::parse_action("New", &pairs)?) |
| 253 | } |
| 254 | |
| 255 | /// `POST /issues/{id}`: mutate `id`'s state, assignees, and/or labels |
| 256 | /// (`ents_forge::issue::edit`) as a signed mutation on the issue's own |
| 257 | /// ref. The posted fields are [`IssueAction::Edit`]'s own, the path's |
| 258 | /// `id` standing in for the CLI's positional argument. |
| 259 | /// |
| 260 | /// # Errors |
| 261 | /// |
| 262 | /// [`crate::Error::BadCsrf`] if the posted token does not match; |
| 263 | /// otherwise propagates [`ents_forge::issue::edit`]'s own failures |
| 264 | /// (including [`ents_forge::Error::NotFound`] when `id` names no issue). |
| 265 | // @relation(model.issue, roots.web-signing, roots.web-session, scope=function) |
| 266 | pub async fn edit<O>( |
| 267 | State(state): State<Arc<AppState<O>>>, |
| 268 | axum::Extension(session): axum::Extension<Session>, |
| 269 | Path(id): Path<String>, |
| 270 | Form(mut pairs): Form<Vec<(String, String)>>, |
| 271 | ) -> Result<impl IntoResponse> |
| 272 | where |
| 273 | O: Find + Write + Send + 'static, |
| 274 | { |
| 275 | super::require_csrf(&session, crate::form::posted_csrf(&pairs))?; |
| 276 | pairs.retain(|(name, _)| name != "id"); |
| 277 | pairs.push(("id".to_owned(), id)); |
| 278 | dispatch(&state, &session, crate::form::parse_action("Edit", &pairs)?) |
| 279 | } |
| 280 | |
| 281 | /// The issue dispatch table: each mutating [`IssueAction`] variant mapped |
| 282 | /// to the same `ents_forge::issue` call `git ents issue`'s own command |
| 283 | /// module makes, with the same edit semantics (an empty label/assignee |
| 284 | /// set leaves the field unchanged) -- `lens.parity`, the web as another |
| 285 | /// caller of the one business-logic path. |
| 286 | // @relation(model.issue, lens.parity, scope=function) |
| 287 | fn dispatch<O>(state: &AppState<O>, session: &Session, action: IssueAction) -> Result<Redirect> |
| 288 | where |
| 289 | O: Find + Write + Send + 'static, |
| 290 | { |
| 291 | let identity = state.identity.as_ref(); |
| 292 | let identity = crate::receive_identity!(identity, crate::pages::member_author(session)); |
| 293 | match action { |
| 294 | IssueAction::New { |
| 295 | title, |
| 296 | body, |
| 297 | state: issue_state, |
| 298 | label, |
| 299 | assignee, |
| 300 | key: _, |
| 301 | } => { |
| 302 | let new = NewIssue { |
| 303 | title: title.unwrap_or_default(), |
| 304 | body: body.unwrap_or_default(), |
| 305 | state: issue_state, |
| 306 | assignees: assignee.into_iter().map(MemberId::new).collect(), |
| 307 | labels: label, |
| 308 | }; |
| 309 | let (id, outcome) = issue::new( |
| 310 | state.refs.as_ref(), |
| 311 | &*state.objects(), |
| 312 | state.events.as_ref(), |
| 313 | new, |
| 314 | &identity, |
| 315 | state.mode, |
| 316 | )?; |
| 317 | crate::error::outcome_to_result(outcome)?; |
| 318 | Ok(Redirect::to(&format!("/issues/{id}"))) |
| 319 | } |
| 320 | IssueAction::Edit { |
| 321 | id, |
| 322 | state: issue_state, |
| 323 | label, |
| 324 | assignee, |
| 325 | key: _, |
| 326 | } => { |
| 327 | let edit = EditIssue { |
| 328 | state: issue_state, |
| 329 | labels: (!label.is_empty()).then_some(label), |
| 330 | assignees: (!assignee.is_empty()) |
| 331 | .then(|| assignee.into_iter().map(MemberId::new).collect()), |
| 332 | }; |
| 333 | let outcome = issue::edit( |
| 334 | state.refs.as_ref(), |
| 335 | &*state.objects(), |
| 336 | state.events.as_ref(), |
| 337 | &id, |
| 338 | edit, |
| 339 | &identity, |
| 340 | state.mode, |
| 341 | )?; |
| 342 | crate::error::outcome_to_result(outcome)?; |
| 343 | Ok(Redirect::to(&format!("/issues/{id}"))) |
| 344 | } |
| 345 | _ => Err(crate::Error::InvalidArgument( |
| 346 | "not a form-backed issue action".to_owned(), |
| 347 | )), |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | /// The form fields `POST /issues/{id}/comment` accepts. |
| 352 | #[derive(Debug, Deserialize)] |
| 353 | pub struct CommentForm { |
| 354 | /// The comment's body text. |
| 355 | body: String, |
| 356 | /// The per-session CSRF token (`roots.web-session`). |
| 357 | csrf: String, |
| 358 | } |
| 359 | |
| 360 | /// `POST /issues/{id}/comment`: a comment naming `issues/<id>` as its |
| 361 | /// context (`model.comment-context`) -- an ordinary |
| 362 | /// [`ents_forge::comment::add`], contextual and unanchored, so it joins the |
| 363 | /// issue's thread the moment it lands. |
| 364 | /// |
| 365 | /// # Errors |
| 366 | /// |
| 367 | /// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise |
| 368 | /// propagates [`ents_forge::comment::add`]'s own failures. |
| 369 | // @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function) |
| 370 | pub async fn comment<O>( |
| 371 | State(state): State<Arc<AppState<O>>>, |
| 372 | axum::Extension(session): axum::Extension<Session>, |
| 373 | Path(id): Path<String>, |
| 374 | Form(form): Form<CommentForm>, |
| 375 | ) -> Result<impl IntoResponse> |
| 376 | where |
| 377 | O: Find + Write + Send + 'static, |
| 378 | { |
| 379 | super::require_csrf(&session, &form.csrf)?; |
| 380 | let identity = state.identity.as_ref(); |
| 381 | let new = ents_forge::comment::NewComment { |
| 382 | body: form.body, |
| 383 | path: None, |
| 384 | lines: None, |
| 385 | rev: "HEAD".to_owned(), |
| 386 | worktree: false, |
| 387 | context: Some(format!("issues/{id}")), |
| 388 | parent: None, |
| 389 | }; |
| 390 | let (_comment_id, outcome) = ents_forge::comment::add( |
| 391 | state.refs.as_ref(), |
| 392 | &*state.objects(), |
| 393 | state.events.as_ref(), |
| 394 | &state.path, |
| 395 | new, |
| 396 | &crate::receive_identity!(identity, crate::pages::member_author(&session)), |
| 397 | state.mode, |
| 398 | )?; |
| 399 | crate::error::outcome_to_result(outcome)?; |
| 400 | Ok(Redirect::to(&format!("/issues/{id}"))) |
| 401 | } |
| 402 | |
| 403 | /// The open-an-issue form (`POST /issues`). State picks from |
| 404 | /// [`state_picker`]'s closed three-option enumeration (the redesign's |
| 405 | /// `StatePicker`/`StateChip`, open / in-progress / closed) rather than the |
| 406 | /// pre-redesign free-text-plus-datalist field: `model.issue` itself still |
| 407 | /// stores state as an arbitrary string (`ents_forge::Issue`'s own doc, |
| 408 | /// "custom states are schema, not a platform feature"), so this form |
| 409 | /// narrowing its own three quick-pick buttons never closes that schema -- |
| 410 | /// a state outside the trio stays reachable through `git ents issue edit` |
| 411 | /// or a direct edit, same as any other schema-level custom field. |
| 412 | fn new_form(session: &Session, known_labels: &[String]) -> Markup { |
| 413 | crate::form::action_form::<IssueAction>( |
| 414 | "New", |
| 415 | session, |
| 416 | &crate::form::Spec { |
| 417 | action: "/issues", |
| 418 | submit: "Open Issue", |
| 419 | cancel: Some("/issues"), |
| 420 | values: &[], |
| 421 | overrides: &[ |
| 422 | ( |
| 423 | "title", |
| 424 | html! { label { "Title" input type="text" name="title"; } }, |
| 425 | ), |
| 426 | ( |
| 427 | "state", |
| 428 | html! { div { label { "State" } (state_picker("open")) } }, |
| 429 | ), |
| 430 | ("label", label_picker(known_labels, &[])), |
| 431 | ( |
| 432 | "assignee", |
| 433 | html! { |
| 434 | label { |
| 435 | "Assignees" |
| 436 | input type="text" name="assignee" placeholder="alice, bob" list="members"; |
| 437 | } |
| 438 | }, |
| 439 | ), |
| 440 | ], |
| 441 | }, |
| 442 | ) |
| 443 | } |
| 444 | |
| 445 | /// The edit-issue form (`POST /issues/{id}`), its fields derived from |
| 446 | /// [`IssueAction::Edit`]'s own shape (the positional `id` is the route's |
| 447 | /// path segment, so no control renders for it) and pre-filled from the |
| 448 | /// current issue. Its `state` field carries the same [`state_picker`] as |
| 449 | /// [`new_form`]'s, for the same reason (see [`new_form`]'s own doc). |
| 450 | fn edit_form(session: &Session, issue: &ents_forge::Issue, known_labels: &[String]) -> Markup { |
| 451 | crate::form::action_form::<IssueAction>( |
| 452 | "Edit", |
| 453 | session, |
| 454 | &crate::form::Spec { |
| 455 | action: "", |
| 456 | submit: "Save", |
| 457 | cancel: None, |
| 458 | values: &[], |
| 459 | overrides: &[ |
| 460 | ( |
| 461 | "state", |
| 462 | html! { div { label { "State" } (state_picker(&issue.state)) } }, |
| 463 | ), |
| 464 | ("label", label_picker(known_labels, &issue.labels)), |
| 465 | ( |
| 466 | "assignee", |
| 467 | html! { |
| 468 | label { |
| 469 | "Assignees" |
| 470 | input type="text" name="assignee" value=(join_members(&issue.assignees)) list="members"; |
| 471 | } |
| 472 | }, |
| 473 | ), |
| 474 | ], |
| 475 | }, |
| 476 | ) |
| 477 | } |
| 478 | |
| 479 | /// The comment-on-this-issue form (`POST /issues/{id}/comment`). |
| 480 | fn comment_form(session: &Session, id: &str) -> Markup { |
| 481 | html! { |
| 482 | form method="post" action=(format!("/issues/{id}/comment")) { |
| 483 | (super::csrf_input(session)) |
| 484 | label { "Body" textarea name="body" {} } |
| 485 | button type="submit" { "Comment" } |
| 486 | } |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | /// The three conventional issue states the redesign's `StatePicker` closes |
| 491 | /// over (`model.issue`'s own field stays an open string; see [`new_form`]'s |
| 492 | /// doc for why the form narrows to these three anyway). |
| 493 | const ISSUE_STATES: [&str; 3] = ["open", "in-progress", "closed"]; |
| 494 | |
| 495 | /// The `.chip`/`.dot` color class for a known state, or `None` for a |
| 496 | /// custom one this form's [`state_picker`] does not enumerate -- shared by |
| 497 | /// [`state_chip`] (the detail card's big pill) and [`state_dot`] (the |
| 498 | /// sidebar row's small status dot), so a state's color is spelled in |
| 499 | /// exactly one place. |
| 500 | fn state_class(state: &str) -> Option<&'static str> { |
| 501 | match state { |
| 502 | "open" => Some("state-open"), |
| 503 | "in-progress" => Some("state-in-progress"), |
| 504 | "closed" => Some("state-closed"), |
| 505 | _ => None, |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | /// The issue detail card's state `dd`: a `.chip.chip-pill` carrying a |
| 510 | /// leading `.dot` and the state's own color (see [`state_class`]), plain |
| 511 | /// neutral for a custom state outside the three [`ISSUE_STATES`]. |
| 512 | fn state_chip(state: &str) -> Markup { |
| 513 | let class = match state_class(state) { |
| 514 | Some(extra) => format!("chip chip-pill {extra}"), |
| 515 | None => "chip chip-pill".to_owned(), |
| 516 | }; |
| 517 | html! { |
| 518 | span class=(class) { |
| 519 | span.dot {} |
| 520 | (state) |
| 521 | } |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | /// The sidebar row's status `.dot` (see [`issues_sidebar`]): green open, |
| 526 | /// amber in-progress, grey closed or custom (see [`state_class`]). |
| 527 | fn state_dot(state: &str) -> Markup { |
| 528 | let class = match state_class(state) { |
| 529 | Some(extra) => format!("dot {extra}"), |
| 530 | None => "dot".to_owned(), |
| 531 | }; |
| 532 | html! { span class=(class) {} } |
| 533 | } |
| 534 | |
| 535 | /// One [`state_picker`] option's class: `.opt`, plus `.active` and the |
| 536 | /// state's own color class (see [`state_class`]) when it is `current`'s |
| 537 | /// own value -- an inactive option stays the picker's plain neutral look |
| 538 | /// (mirrors the design handoff's own `statePicker`, which colors only the |
| 539 | /// selected option). |
| 540 | fn picker_opt_class(current: &str, state: &str) -> String { |
| 541 | if current == state { |
| 542 | match state_class(state) { |
| 543 | Some(extra) => format!("opt active {extra}"), |
| 544 | None => "opt active".to_owned(), |
| 545 | } |
| 546 | } else { |
| 547 | "opt".to_owned() |
| 548 | } |
| 549 | } |
| 550 | |
| 551 | /// The state `.picker` both [`new_form`] and [`edit_form`] render: three |
| 552 | /// `name="state"` radios, one per [`ISSUE_STATES`] entry, styled as |
| 553 | /// `.picker .opt` pills (a `hidden` native radio inside a `<label>` still |
| 554 | /// toggles on click -- label-click activation reaches a `hidden` control |
| 555 | /// same as any other -- so the pill shows no native radio glyph without |
| 556 | /// needing any stylesheet change). Exactly one radio is always checked, so |
| 557 | /// the field is never posted empty: `current` itself when it names one of |
| 558 | /// the three, or a fourth unlabeled hidden radio carrying `current` |
| 559 | /// verbatim when it does not (a custom state this form's picker does not |
| 560 | /// enumerate stays intact until the reader deliberately picks a different |
| 561 | /// one). |
| 562 | fn state_picker(current: &str) -> Markup { |
| 563 | html! { |
| 564 | div.picker { |
| 565 | @for state in ISSUE_STATES { |
| 566 | label class=(picker_opt_class(current, state)) { |
| 567 | input type="radio" name="state" value=(state) checked[state == current] hidden; |
| 568 | span.dot {} |
| 569 | (state) |
| 570 | } |
| 571 | } |
| 572 | @if !ISSUE_STATES.contains(¤t) { |
| 573 | input type="radio" name="state" value=(current) checked hidden; |
| 574 | } |
| 575 | } |
| 576 | } |
| 577 | } |
| 578 | |
| 579 | /// Every label already used across every issue in this repository, deduped |
| 580 | /// and sorted -- [`label_picker`]'s "pick existing" set, derived from the |
| 581 | /// same `issue::list_all` read [`issues_sidebar`] renders from rather than |
| 582 | /// a second query of its own. |
| 583 | fn known_labels(rows: &[(String, ents_forge::Issue)]) -> Vec<String> { |
| 584 | let mut labels: Vec<String> = Vec::new(); |
| 585 | for (_, issue) in rows { |
| 586 | for label in &issue.labels { |
| 587 | if !labels.contains(label) { |
| 588 | labels.push(label.clone()); |
| 589 | } |
| 590 | } |
| 591 | } |
| 592 | labels.sort(); |
| 593 | labels |
| 594 | } |
| 595 | |
| 596 | /// The labels field both [`new_form`] and [`edit_form`] render: `known`'s |
| 597 | /// labels previewed as `.label-chip` (`.on` for one already in `current`), |
| 598 | /// then the actual control -- a single free-text `input[name=label]` |
| 599 | /// (spelled as [`IssueAction`]'s own `label` field, which |
| 600 | /// [`crate::form::parse_action`] splits on commas/whitespace) pre-filled |
| 601 | /// from `current` and completed by a [`label_datalist`] of `known`'s own |
| 602 | /// names. Unlike [`state_picker`]'s single-choice radios, real |
| 603 | /// independently-toggleable checkboxes are not this control's shape here: |
| 604 | /// a checkbox group needs client-side script to merge checkbox state into |
| 605 | /// the text value a no-JS post also carries (excluded -- this crate works |
| 606 | /// with no JS at all). The datalist gives "pick existing" a real, working |
| 607 | /// affordance; typing any other word is "type-and-create". |
| 608 | fn label_picker(known: &[String], current: &[String]) -> Markup { |
| 609 | html! { |
| 610 | div { |
| 611 | label { "Labels" } |
| 612 | @if !known.is_empty() { |
| 613 | div.picker { |
| 614 | @for label in known { |
| 615 | @let on = current.iter().any(|applied| applied == label); |
| 616 | span class={ "label-chip" (if on { " on" } else { "" }) } { (label) } |
| 617 | } |
| 618 | } |
| 619 | } |
| 620 | input |
| 621 | type="text" |
| 622 | name="label" |
| 623 | value=(current.join(", ")) |
| 624 | placeholder="bug, gate" |
| 625 | list="issue-labels"; |
| 626 | (label_datalist(known)) |
| 627 | } |
| 628 | } |
| 629 | } |
| 630 | |
| 631 | /// The `datalist` of every already-used label [`label_picker`]'s free-text |
| 632 | /// input completes from. Rendered once per form; the two forms never |
| 633 | /// share a page, so the id never collides (same reasoning the |
| 634 | /// pre-redesign `state_datalist` carried). |
| 635 | fn label_datalist(known: &[String]) -> Markup { |
| 636 | html! { |
| 637 | datalist id="issue-labels" { |
| 638 | @for label in known { option value=(label) {} } |
| 639 | } |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | /// Render a member set for display, comma-joined (`join_members(&[])` is the |
| 644 | /// empty string, so an unassigned issue shows a blank cell rather than a |
| 645 | /// stray separator). |
| 646 | fn join_members(members: &[MemberId]) -> String { |
| 647 | members |
| 648 | .iter() |
| 649 | .map(MemberId::as_str) |
| 650 | .collect::<Vec<_>>() |
| 651 | .join(", ") |
| 652 | } |
| 653 | |