Skip to main content

slack_messaging/blocks/
card.rs

1use crate::blocks::elements::{Button, Image};
2use crate::composition_objects::TextContent;
3use crate::errors::ValidationErrorKind;
4use crate::validators::*;
5
6use serde::Serialize;
7use slack_messaging_derive::Builder;
8
9/// [Card block](https://docs.slack.dev/reference/block-kit/blocks/card-block) representation.
10///
11/// # Fields and Validations
12///
13/// For more details, see the [official documentation](https://docs.slack.dev/reference/block-kit/blocks/card-block).
14///
15/// | Field | Type | Required | Validation |
16/// |-------|------|----------|------------|
17/// | block_id | String | No | Maximum 255 characters |
18/// | hero_image | [Image] | No | N/A |
19/// | icon | [Image] | No | N/A |
20/// | title | [TextContent] | No | Maximum 150 characters |
21/// | subtitle | [TextContent] | No | Maximum 150 characters |
22/// | body | [TextContent] | No | Maximum 200 characters |
23/// | actions | Vec<[Button]> | No | N/A |
24///
25/// # Validation Across Fields
26///
27/// * At least one of `hero_image`, `title`, `actions`, or `body` is required.
28///
29/// # Example
30///
31/// ```
32/// use slack_messaging::{plain_text, mrkdwn};
33/// use slack_messaging::blocks::Card;
34/// use slack_messaging::blocks::elements::{Button, Image};
35/// # use std::error::Error;
36///
37/// # fn try_main() -> Result<(), Box<dyn Error>> {
38/// let card = Card::builder()
39///     .icon(
40///         Image::builder()
41///             .image_url("https://picsum.photos/36/36")
42///             .alt_text("Icon")
43///             .build()?
44///     )
45///     .title(mrkdwn!("Lumon Industries")?)
46///     .subtitle(mrkdwn!("Committed to work-life balance")?)
47///     .hero_image(
48///         Image::builder()
49///             .image_url("https://picsum.photos/400/300")
50///             .alt_text("Sample hero image")
51///             .build()?
52///     )
53///     .body(mrkdwn!("Please enjoy each card equally.")?)
54///     .action(
55///         Button::builder()
56///             .text(plain_text!("Action Button")?)
57///             .action_id("button_action")
58///             .build()?
59///     )
60///     .build()?;
61///
62/// let expected = serde_json::json!({
63///     "type": "card",
64///     "icon": {
65///         "type": "image",
66///         "image_url": "https://picsum.photos/36/36",
67///         "alt_text": "Icon"
68///     },
69///     "title": {
70///         "type": "mrkdwn",
71///         "text": "Lumon Industries"
72///     },
73///     "subtitle": {
74///         "type": "mrkdwn",
75///         "text": "Committed to work-life balance"
76///     },
77///     "hero_image": {
78///         "type": "image",
79///         "image_url": "https://picsum.photos/400/300",
80///         "alt_text": "Sample hero image"
81///     },
82///     "body": {
83///         "type": "mrkdwn",
84///         "text": "Please enjoy each card equally."
85///     },
86///     "actions": [
87///         {
88///             "type": "button",
89///             "text": {
90///                 "type": "plain_text",
91///                 "text": "Action Button"
92///             },
93///             "action_id": "button_action"
94///         }
95///     ]
96/// });
97///
98/// let json = serde_json::to_value(card).unwrap();
99///
100/// assert_eq!(json, expected);
101/// #     Ok(())
102/// # }
103/// # fn main() {
104/// #     try_main().unwrap()
105/// # }
106/// ```
107#[derive(Debug, Clone, Serialize, PartialEq, Builder)]
108#[builder(validate = "validate")]
109#[serde(tag = "type", rename = "card")]
110pub struct Card {
111    #[serde(skip_serializing_if = "Option::is_none")]
112    #[builder(validate("text::max_255"))]
113    pub(crate) block_id: Option<String>,
114
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub(crate) hero_image: Option<Image>,
117
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub(crate) icon: Option<Image>,
120
121    #[serde(skip_serializing_if = "Option::is_none")]
122    #[builder(validate("text_object::max_150"))]
123    pub(crate) title: Option<TextContent>,
124
125    #[serde(skip_serializing_if = "Option::is_none")]
126    #[builder(validate("text_object::max_150"))]
127    pub(crate) subtitle: Option<TextContent>,
128
129    #[serde(skip_serializing_if = "Option::is_none")]
130    #[builder(validate("text_object::max_200"))]
131    pub(crate) body: Option<TextContent>,
132
133    #[serde(skip_serializing_if = "Option::is_none")]
134    #[builder(push_item = "action")]
135    pub(crate) actions: Option<Vec<Button>>,
136}
137
138fn validate(val: &Card) -> Vec<ValidationErrorKind> {
139    if val.hero_image.is_none()
140        && val.title.is_none()
141        && val
142            .actions
143            .as_ref()
144            .is_none_or(|actions| actions.is_empty())
145        && val.body.is_none()
146    {
147        vec![ValidationErrorKind::AtLeastOneOf4(
148            "hero_image",
149            "title",
150            "actions",
151            "body",
152        )]
153    } else {
154        vec![]
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::composition_objects::test_helpers::*;
162    use crate::errors::*;
163
164    #[test]
165    fn it_implements_builder() {
166        let expected = Card {
167            block_id: Some("card1".to_string()),
168            hero_image: Some(Image {
169                image_url: Some("https://picsum.photos/400/300".into()),
170                alt_text: Some("Sample hero image".into()),
171                slack_file: None,
172            }),
173            icon: Some(Image {
174                image_url: Some("https://picsum.photos/36/36".into()),
175                alt_text: Some("Icon".into()),
176                slack_file: None,
177            }),
178            title: Some(plain_text("Lumon Industries").into()),
179            subtitle: Some(plain_text("Committed to work-life balance").into()),
180            body: Some(plain_text("Please enjoy each card equally.").into()),
181            actions: Some(vec![Button {
182                text: Some(plain_text("Action Button")),
183                action_id: Some("button_action".into()),
184                url: None,
185                value: None,
186                style: None,
187                confirm: None,
188                accessibility_label: None,
189            }]),
190        };
191
192        let val = Card::builder()
193            .set_block_id(Some("card1"))
194            .set_hero_image(Some(
195                Image::builder()
196                    .image_url("https://picsum.photos/400/300")
197                    .alt_text("Sample hero image")
198                    .build()
199                    .unwrap(),
200            ))
201            .set_icon(Some(
202                Image::builder()
203                    .image_url("https://picsum.photos/36/36")
204                    .alt_text("Icon")
205                    .build()
206                    .unwrap(),
207            ))
208            .set_title(Some(plain_text("Lumon Industries")))
209            .set_subtitle(Some(plain_text("Committed to work-life balance")))
210            .set_body(Some(plain_text("Please enjoy each card equally.")))
211            .set_actions(Some(vec![
212                Button::builder()
213                    .text(plain_text("Action Button"))
214                    .action_id("button_action")
215                    .build()
216                    .unwrap(),
217            ]))
218            .build()
219            .unwrap();
220
221        assert_eq!(val, expected);
222
223        let val = Card::builder()
224            .block_id("card1")
225            .hero_image(
226                Image::builder()
227                    .image_url("https://picsum.photos/400/300")
228                    .alt_text("Sample hero image")
229                    .build()
230                    .unwrap(),
231            )
232            .icon(
233                Image::builder()
234                    .image_url("https://picsum.photos/36/36")
235                    .alt_text("Icon")
236                    .build()
237                    .unwrap(),
238            )
239            .title(plain_text("Lumon Industries"))
240            .subtitle(plain_text("Committed to work-life balance"))
241            .body(plain_text("Please enjoy each card equally."))
242            .actions(vec![
243                Button::builder()
244                    .text(plain_text("Action Button"))
245                    .action_id("button_action")
246                    .build()
247                    .unwrap(),
248            ])
249            .build()
250            .unwrap();
251
252        assert_eq!(val, expected);
253    }
254
255    #[test]
256    fn it_implements_push_item_method() {
257        let expected = Card {
258            block_id: None,
259            hero_image: None,
260            icon: None,
261            title: None,
262            subtitle: None,
263            body: Some(plain_text("Please enjoy each card equally.").into()),
264            actions: Some(vec![Button {
265                text: Some(plain_text("Action Button")),
266                action_id: Some("button_action".into()),
267                url: None,
268                value: None,
269                style: None,
270                confirm: None,
271                accessibility_label: None,
272            }]),
273        };
274
275        let val = Card::builder()
276            .body(plain_text("Please enjoy each card equally."))
277            .action(
278                Button::builder()
279                    .text(plain_text("Action Button"))
280                    .action_id("button_action")
281                    .build()
282                    .unwrap(),
283            )
284            .build()
285            .unwrap();
286
287        assert_eq!(val, expected);
288    }
289
290    #[test]
291    fn it_requires_title_less_than_150_characters_long() {
292        let err = Card::builder()
293            .title(plain_text("a".repeat(151)))
294            .build()
295            .unwrap_err();
296        assert_eq!(err.object(), "Card");
297
298        let errors = err.field("title");
299        assert!(errors.includes(ValidationErrorKind::MaxTextLength(150)));
300    }
301
302    #[test]
303    fn it_requires_subtitle_less_than_150_characters_long() {
304        let err = Card::builder()
305            .title(plain_text("Valid Title"))
306            .subtitle(plain_text("a".repeat(151)))
307            .build()
308            .unwrap_err();
309        assert_eq!(err.object(), "Card");
310
311        let errors = err.field("subtitle");
312        assert!(errors.includes(ValidationErrorKind::MaxTextLength(150)));
313    }
314
315    #[test]
316    fn it_requires_body_less_than_200_characters_long() {
317        let err = Card::builder()
318            .body(plain_text("a".repeat(201)))
319            .build()
320            .unwrap_err();
321        assert_eq!(err.object(), "Card");
322
323        let errors = err.field("body");
324        assert!(errors.includes(ValidationErrorKind::MaxTextLength(200)));
325    }
326
327    #[test]
328    fn it_requires_at_least_one_of_hero_image_title_actions_body() {
329        let err = Card::builder().build().unwrap_err();
330        assert_eq!(err.object(), "Card");
331
332        let errors = err.across_fields();
333        assert!(errors.includes(ValidationErrorKind::AtLeastOneOf4(
334            "hero_image",
335            "title",
336            "actions",
337            "body"
338        )));
339
340        let card = Card::builder()
341            .hero_image(
342                Image::builder()
343                    .image_url("https://picsum.photos/400/300")
344                    .alt_text("Sample hero image")
345                    .build()
346                    .unwrap(),
347            )
348            .build();
349        assert!(card.is_ok());
350
351        let card = Card::builder()
352            .title(plain_text("Lumon Industries"))
353            .build();
354        assert!(card.is_ok());
355
356        let card = Card::builder()
357            .body(plain_text("Please enjoy each card equally."))
358            .build();
359        assert!(card.is_ok());
360
361        let card = Card::builder()
362            .action(
363                Button::builder()
364                    .text(plain_text("Action Button"))
365                    .action_id("button_action")
366                    .build()
367                    .unwrap(),
368            )
369            .build();
370        assert!(card.is_ok());
371    }
372
373    #[test]
374    fn it_requires_block_id_less_than_255_characters_long() {
375        let err = Card::builder()
376            .block_id("a".repeat(256))
377            .build()
378            .unwrap_err();
379        assert_eq!(err.object(), "Card");
380
381        let errors = err.field("block_id");
382        assert!(errors.includes(ValidationErrorKind::MaxTextLength(255)));
383    }
384}