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