| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | use forge_core::store::commit_status::{CommitStatus, StatusState, combine}; |
| 6 | use topcoat::Result; |
| 7 | use topcoat::view::{View, component, view}; |
| 8 | |
| 9 | use crate::i18n::Lang; |
| 10 | use crate::i18n::code::{self as g, K}; |
| 11 | |
| 12 | |
| 13 | |
| 14 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 15 | pub struct StatusLang(pub Lang); |
| 16 | |
| 17 | impl Default for StatusLang { |
| 18 | fn default() -> Self { |
| 19 | StatusLang(Lang::En) |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | |
| 24 | #[derive(Clone)] |
| 25 | pub struct StatusItem { |
| 26 | pub context: String, |
| 27 | |
| 28 | pub icon: &'static str, |
| 29 | pub icon_class: String, |
| 30 | pub description: String, |
| 31 | pub has_description: bool, |
| 32 | pub link: String, |
| 33 | pub has_link: bool, |
| 34 | } |
| 35 | |
| 36 | impl From<&CommitStatus> for StatusItem { |
| 37 | fn from(status: &CommitStatus) -> Self { |
| 38 | let description = status.description.clone().unwrap_or_default(); |
| 39 | let link = status.target_url.clone().unwrap_or_default(); |
| 40 | Self { |
| 41 | context: status.context.clone(), |
| 42 | icon: icon(status.state), |
| 43 | icon_class: format!("status-icon status-{}", css(status.state)), |
| 44 | has_description: !description.is_empty(), |
| 45 | description, |
| 46 | has_link: !link.is_empty(), |
| 47 | link, |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | |
| 53 | #[derive(Clone, Default)] |
| 54 | pub struct StatusSummary { |
| 55 | |
| 56 | pub state: Option<StatusState>, |
| 57 | pub items: Vec<StatusItem>, |
| 58 | } |
| 59 | |
| 60 | impl StatusSummary { |
| 61 | pub fn from_rows(rows: &[CommitStatus]) -> Self { |
| 62 | Self { |
| 63 | state: combine(rows.iter().map(|row| row.state)), |
| 64 | items: rows.iter().map(StatusItem::from).collect(), |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | |
| 70 | pub fn icon(state: StatusState) -> &'static str { |
| 71 | match state { |
| 72 | StatusState::Success => "✓", |
| 73 | StatusState::Failure | StatusState::Error => "✗", |
| 74 | StatusState::Pending => "●", |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | |
| 79 | pub fn css(state: StatusState) -> &'static str { |
| 80 | match state { |
| 81 | StatusState::Success => "success", |
| 82 | StatusState::Failure | StatusState::Error => "failure", |
| 83 | StatusState::Pending => "pending", |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | |
| 88 | |
| 89 | #[component] |
| 90 | pub async fn commit_status( |
| 91 | summary: StatusSummary, |
| 92 | #[default] lang: StatusLang, |
| 93 | ) -> Result<impl View> { |
| 94 | let lang = lang.0; |
| 95 | let has_state = summary.state.is_some(); |
| 96 | let state = summary.state.unwrap_or(StatusState::Pending); |
| 97 | let icon_text = icon(state); |
| 98 | let css_class = format!("status-icon status-{}", css(state)); |
| 99 | let title = format!( |
| 100 | "{}{}", |
| 101 | g::t(lang, K::CommitStatusPrefix), |
| 102 | g::state_word(lang, state) |
| 103 | ); |
| 104 | let items = summary.items; |
| 105 | Ok(view! { |
| 106 | if has_state { |
| 107 | <details class="commit-status"> |
| 108 | <summary class=(css_class.as_str()) title=(title.as_str()) aria-label=(title.as_str())> |
| 109 | <span aria-hidden="true">(icon_text)</span> |
| 110 | </summary> |
| 111 | status_list(items: items, lang: lang) |
| 112 | </details> |
| 113 | } |
| 114 | }) |
| 115 | } |
| 116 | |
| 117 | |
| 118 | |
| 119 | #[component] |
| 120 | pub async fn checks_summary( |
| 121 | summary: StatusSummary, |
| 122 | #[default] lang: StatusLang, |
| 123 | ) -> Result<impl View> { |
| 124 | let lang = lang.0; |
| 125 | let (text, state) = match summary.state { |
| 126 | None => (g::t(lang, K::NoChecksReported), None), |
| 127 | Some(StatusState::Success) => (g::t(lang, K::AllChecksPassed), Some(StatusState::Success)), |
| 128 | Some(StatusState::Pending) => (g::t(lang, K::ChecksPending), Some(StatusState::Pending)), |
| 129 | Some(StatusState::Failure) | Some(StatusState::Error) => { |
| 130 | (g::t(lang, K::SomeChecksFailed), Some(StatusState::Failure)) |
| 131 | } |
| 132 | }; |
| 133 | let icon_text = state.map_or("", icon); |
| 134 | let css_class = format!("checks-summary status-{}", state.map_or("none", css)); |
| 135 | let items = summary.items; |
| 136 | let has_items = !items.is_empty(); |
| 137 | Ok(view! { |
| 138 | <div class=(css_class.as_str())> |
| 139 | <span class="status-icon" aria-hidden="true">(icon_text)</span> |
| 140 | <strong>(text)</strong> |
| 141 | if has_items { |
| 142 | <details class="checks-details"> |
| 143 | <summary class="small muted">(g::t(lang, K::ShowChecks))</summary> |
| 144 | status_list(items: items, lang: lang) |
| 145 | </details> |
| 146 | } |
| 147 | </div> |
| 148 | }) |
| 149 | } |
| 150 | |
| 151 | |
| 152 | #[component] |
| 153 | pub async fn status_list(items: Vec<StatusItem>, lang: Lang) -> Result<impl View> { |
| 154 | Ok(view! { |
| 155 | <ul class="status-list"> |
| 156 | for item in items.iter() { |
| 157 | <li class="status-item"> |
| 158 | <span class=(item.icon_class.as_str())>(item.icon)</span> |
| 159 | <code>(item.context.as_str())</code> |
| 160 | if item.has_description { |
| 161 | <span class="muted small">(item.description.as_str())</span> |
| 162 | } |
| 163 | if item.has_link { |
| 164 | <a class="small" href=(item.link.as_str()) rel="nofollow noopener noreferrer">(g::t(lang, K::DetailsLink))</a> |
| 165 | } |
| 166 | </li> |
| 167 | } |
| 168 | </ul> |
| 169 | }) |
| 170 | } |