forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.8 KiB
1//! Commit status indicators. `commit_status` is the combined state as an icon, with a dropdown of
2//! every context (the commit list, commit page and PR commits tab). `checks_summary` is the line in
3//! the pull request merge box.
4
5use forge_core::store::commit_status::{CommitStatus, StatusState, combine};
6use topcoat::Result;
7use topcoat::view::{View, component, view};
8
9use crate::i18n::Lang;
10use crate::i18n::code::{self as g, K};
11
12/// The language of the status indicators. It defaults to English, so a caller that does not pass
13/// one (the pull request page, for now) keeps its English text.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct StatusLang(pub Lang);
16
17impl Default for StatusLang {
18 fn default() -> Self {
19 StatusLang(Lang::En)
20 }
21}
22
23/// One context's report, prepared for display.
24#[derive(Clone)]
25pub struct StatusItem {
26 pub context: String,
27 /// The state's icon, and the CSS classes that colour it.
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
36impl 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/// Everything the indicators show about one commit.
53#[derive(Clone, Default)]
54pub struct StatusSummary {
55 /// Combined state, `None` when the commit has no statuses.
56 pub state: Option<StatusState>,
57 pub items: Vec<StatusItem>,
58}
59
60impl 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/// The icon for a state: check, cross or dot.
70pub fn icon(state: StatusState) -> &'static str {
71 match state {
72 StatusState::Success => "✓",
73 StatusState::Failure | StatusState::Error => "✗",
74 StatusState::Pending => "●",
75 }
76}
77
78/// CSS modifier for a state. `error` shares the failure colour.
79pub 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/// The combined icon with a dropdown listing every context. Renders nothing when the commit has
88/// no statuses.
89#[component]
90pub 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/// The merge box line: "All checks have passed", "Some checks failed" or "Checks pending", with
118/// the contexts beneath it.
119#[component]
120pub 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/// The contexts of one commit: icon, context name, description and a details link.
152#[component]
153pub 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}