Skip to main content

slack_messaging/blocks/
task_card.rs

1use crate::blocks::RichText;
2use crate::blocks::elements::UrlSource;
3use crate::validators::*;
4
5use serde::Serialize;
6use slack_messaging_derive::Builder;
7
8/// [Task card](https://docs.slack.dev/reference/block-kit/blocks/task-card-block) representation.
9///
10/// # Fields and Validations
11///
12/// For more details, see the [official documentation](https://docs.slack.dev/reference/block-kit/blocks/task-card-block).
13///
14/// | Field | Type | Required | Validation |
15/// |-------|------|----------|------------|
16/// | task_id | String | Yes | N/A |
17/// | title | String | Yes | N/A |
18/// | details | [RichText] | No | Have exactly one element |
19/// | output | [RichText] | No | Have exactly one element |
20/// | sources | Vec<[UrlSource]> | No | N/A |
21/// | status | [TaskStatus] | No | N/A |
22/// | block_id | String | No | Maximum 255 characters |
23///
24/// # Example
25///
26/// The following is reproduction of [the sample task
27/// card](https://docs.slack.dev/reference/block-kit/blocks/task-card-block#examples).
28///
29/// ```
30/// use slack_messaging::blocks::{RichText, TaskCard, TaskStatus};
31/// use slack_messaging::blocks::elements::UrlSource;
32/// use slack_messaging::blocks::rich_text::RichTextSection;
33/// use slack_messaging::blocks::rich_text::types::RichTextElementText;
34/// # use std::error::Error;
35///
36/// # fn try_main() -> Result<(), Box<dyn Error>> {
37/// let task_card = TaskCard::builder()
38///     .task_id("task_1")
39///     .title("Fetching weather data")
40///     .status(TaskStatus::Pending)
41///     .output(
42///         RichText::builder()
43///             .element(
44///                 RichTextSection::builder()
45///                     .element(
46///                         RichTextElementText::builder()
47///                             .text("Found weather data for Chicago from 2 sources")
48///                             .build()?
49///                     )
50///                     .build()?
51///             )
52///             .build()?
53///     )
54///     .source(
55///         UrlSource::builder()
56///             .url("https://weather.com/")
57///             .text("weather.com")
58///             .build()?
59///     )
60///     .source(
61///         UrlSource::builder()
62///             .url("https://www.accuweather.com/")
63///             .text("accuweather.com")
64///             .build()?
65///     )
66///     .build()?;
67///
68/// let expected = serde_json::json!({
69///    "type": "task_card",
70///    "task_id": "task_1",
71///    "title": "Fetching weather data",
72///    "status": "pending",
73///    "output": {
74///        "type": "rich_text",
75///        "elements": [
76///            {
77///                "type": "rich_text_section",
78///                "elements": [
79///                    {
80///                        "type": "text",
81///                        "text": "Found weather data for Chicago from 2 sources"
82///                    }
83///                ]
84///            }
85///        ]
86///    },
87///    "sources": [
88///        {
89///            "type": "url",
90///            "url": "https://weather.com/",
91///            "text": "weather.com"
92///        },
93///        {
94///            "type": "url",
95///            "url": "https://www.accuweather.com/",
96///            "text": "accuweather.com"
97///        }
98///    ]
99/// });
100///
101/// let json = serde_json::to_value(task_card).unwrap();
102///
103/// assert_eq!(json, expected);
104/// #     Ok(())
105/// # }
106/// # fn main() {
107/// #     try_main().unwrap()
108/// # }
109/// ```
110#[derive(Debug, Clone, Serialize, PartialEq, Builder)]
111#[serde(tag = "type", rename = "task_card")]
112pub struct TaskCard {
113    #[builder(validate("required"))]
114    pub(crate) task_id: Option<String>,
115
116    #[builder(validate("required"))]
117    pub(crate) title: Option<String>,
118
119    #[serde(skip_serializing_if = "Option::is_none")]
120    #[builder(validate("rich_text::single_element"))]
121    pub(crate) details: Option<RichText>,
122
123    #[serde(skip_serializing_if = "Option::is_none")]
124    #[builder(validate("rich_text::single_element"))]
125    pub(crate) output: Option<RichText>,
126
127    #[serde(skip_serializing_if = "Option::is_none")]
128    #[builder(push_item = "source")]
129    pub(crate) sources: Option<Vec<UrlSource>>,
130
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub(crate) status: Option<TaskStatus>,
133
134    #[serde(skip_serializing_if = "Option::is_none")]
135    #[builder(validate("text::max_255"))]
136    pub(crate) block_id: Option<String>,
137}
138
139/// Values that can be set to the status field of [TaskCard].
140#[derive(Debug, Copy, Clone, Serialize, PartialEq)]
141#[serde(rename_all = "snake_case")]
142pub enum TaskStatus {
143    Pending,
144    InProgress,
145    Complete,
146    Error,
147}
148
149#[cfg(test)]
150mod tests {
151    use super::super::rich_text::{
152        RichText, test_helpers::section, types::RichTextElementType, types::test_helpers::el_text,
153    };
154    use super::*;
155    use crate::blocks::rich_text::RichTextSubElement;
156    use crate::errors::*;
157
158    #[test]
159    fn it_implements_builder() {
160        let output = rich_text(vec![el_text("output of task card")]);
161        let details = rich_text(vec![el_text("details for task card")]);
162        let sources = vec![
163            url_source("https://weather.com/", "weather.com"),
164            url_source("https://www.accuweather.com/", "accuweather.com"),
165        ];
166
167        let expected = TaskCard {
168            task_id: Some("task_1".into()),
169            title: Some("Fetching weather data".into()),
170            status: Some(TaskStatus::Pending),
171            output: Some(output.clone()),
172            details: Some(details.clone()),
173            sources: Some(sources.clone()),
174            block_id: Some("task_card_0".into()),
175        };
176
177        let val = TaskCard::builder()
178            .set_task_id(Some("task_1"))
179            .set_title(Some("Fetching weather data"))
180            .set_status(Some(TaskStatus::Pending))
181            .set_output(Some(output.clone()))
182            .set_details(Some(details.clone()))
183            .set_sources(Some(sources.clone()))
184            .set_block_id(Some("task_card_0"))
185            .build()
186            .unwrap();
187
188        assert_eq!(val, expected);
189
190        let val = TaskCard::builder()
191            .task_id("task_1")
192            .title("Fetching weather data")
193            .status(TaskStatus::Pending)
194            .output(output.clone())
195            .details(details.clone())
196            .sources(sources.clone())
197            .block_id("task_card_0")
198            .build()
199            .unwrap();
200
201        assert_eq!(val, expected);
202    }
203
204    #[test]
205    fn it_implements_push_item_method() {
206        let expected = TaskCard {
207            task_id: Some("task_1".into()),
208            title: Some("Fetching weather data".into()),
209            status: None,
210            output: None,
211            details: None,
212            sources: Some(vec![
213                url_source("https://weather.com/", "weather.com"),
214                url_source("https://www.accuweather.com/", "accuweather.com"),
215            ]),
216            block_id: None,
217        };
218
219        let val = TaskCard::builder()
220            .task_id("task_1")
221            .title("Fetching weather data")
222            .source(url_source("https://weather.com/", "weather.com"))
223            .source(url_source(
224                "https://www.accuweather.com/",
225                "accuweather.com",
226            ))
227            .build()
228            .unwrap();
229
230        assert_eq!(val, expected);
231    }
232
233    #[test]
234    fn it_requires_task_id_field() {
235        let err = TaskCard::builder().title("foo").build().unwrap_err();
236        assert_eq!(err.object(), "TaskCard");
237
238        let errors = err.field("task_id");
239        assert!(errors.includes(ValidationErrorKind::Required));
240    }
241
242    #[test]
243    fn it_requires_title_field() {
244        let err = TaskCard::builder().task_id("task_1").build().unwrap_err();
245        assert_eq!(err.object(), "TaskCard");
246
247        let errors = err.field("title");
248        assert!(errors.includes(ValidationErrorKind::Required));
249    }
250
251    #[test]
252    fn it_requires_details_and_output_fields_to_have_exactly_one_element() {
253        let err = TaskCard::builder()
254            .task_id("task_1")
255            .title("foo")
256            .details(rich_text(vec![]))
257            .build()
258            .unwrap_err();
259        assert_eq!(err.object(), "TaskCard");
260
261        let errors = err.field("details");
262        assert!(errors.includes(ValidationErrorKind::RichTextSingleElement));
263
264        let err = TaskCard::builder()
265            .task_id("task_1")
266            .title("foo")
267            .output(rich_text(vec![]))
268            .build()
269            .unwrap_err();
270        assert_eq!(err.object(), "TaskCard");
271
272        let errors = err.field("output");
273        assert!(errors.includes(ValidationErrorKind::RichTextSingleElement));
274    }
275
276    #[test]
277    fn it_requires_block_id_less_than_255_characters_long() {
278        let err = TaskCard::builder()
279            .task_id("task_1")
280            .title("foo")
281            .block_id("a".repeat(256))
282            .build()
283            .unwrap_err();
284        assert_eq!(err.object(), "TaskCard");
285
286        let errors = err.field("block_id");
287        assert!(errors.includes(ValidationErrorKind::MaxTextLength(255)));
288    }
289
290    fn rich_text(texts: Vec<RichTextElementType>) -> RichText {
291        RichText {
292            block_id: None,
293            elements: Some(
294                texts
295                    .into_iter()
296                    .map(|text| section(vec![text]))
297                    .map(RichTextSubElement::from)
298                    .collect(),
299            ),
300        }
301    }
302
303    fn url_source(url: &str, text: &str) -> UrlSource {
304        UrlSource {
305            url: Some(url.into()),
306            text: Some(text.into()),
307        }
308    }
309}