Skip to main content

slack_messaging/blocks/
container.rs

1use crate::blocks::{elements, Actions, Context, Divider, File, Header, Image, Input, RichText, Section, Table, Video};
2use crate::composition_objects::{Plain, Text, TextContent};
3use crate::validators::*;
4
5use serde::Serialize;
6use slack_messaging_derive::Builder;
7
8/// [Container block](https://docs.slack.dev/reference/block-kit/blocks/container-block)
9/// representation.
10///
11/// # Fields and Validations
12///
13/// For more details, see the [official
14/// documentation](https://docs.slack.dev/reference/block-kit/blocks/container-block).
15///
16/// | Field | Type | Required | Validation |
17/// |-------|------|----------|------------|
18/// | title | [`Text<Plain>`] | Yes | Maximum 150 characters |
19/// | child_blocks | Vec<[ContainerChildBlock]> | Yes | Maximum 10 items |
20/// | block_id | String | No | Maximum 255 characters |
21/// | width | [ContainerWidth] | No | N/A |
22/// | subtitle | [TextContent] | No | Maximum 150 characters |
23/// | icon | [elements::Image] | No | The `alt_text` field of the image must be at most 2000 characters |
24/// | is_collapsible | bool | No | N/A |
25/// | default_collapsed | bool | No | N/A |
26///
27/// # Example
28///
29/// The following is reproduction of [the sample container
30/// block](https://docs.slack.dev/reference/block-kit/blocks/container-block#examples).
31///
32/// ```
33/// use slack_messaging::{plain_text, mrkdwn};
34/// use slack_messaging::blocks::*;
35/// use slack_messaging::blocks::elements::Button;
36/// use slack_messaging::composition_objects::{Plain, Text};
37/// # use std::error::Error;
38///
39/// # fn try_main() -> Result<(), Box<dyn Error>> {
40/// let container = Container::builder()
41///     .block_id("bkb_container_bulk_update")
42///     .title(plain_text!("Bulk update: 2 records selected")?)
43///     .subtitle(plain_text!("Review changes before confirming")?)
44///     .is_collapsible(true)
45///     .child_block(
46///         Section::builder()
47///             .block_id("record-row-1")
48///             .text(mrkdwn!("*DCW-1024*\nStatus: Open → Closed\nAssignee: @princessdonut → @carl")?)
49///             .build()?
50///     )
51///     .child_block(
52///         Divider::builder()
53///             .block_id("bulk-div-1")
54///             .build()?
55///     )
56///     .child_block(
57///         Section::builder()
58///             .block_id("record-row-2")
59///             .text(mrkdwn!("*DCW-1025*\nStatus: In Progress → Closed\nAssignee: @mordecai → @carl")?)
60///             .build()?
61///     )
62///     .child_block(
63///         Divider::builder()
64///             .block_id("bulk-div-2")
65///             .build()?
66///     )
67///     .child_block(
68///         Context::builder()
69///             .block_id("bulk-status-bar")
70///             .element(mrkdwn!(":white_check_mark: 2 records will be updated • Status → Closed • Assignee → @carl")?)
71///             .build()?
72///     )
73///     .child_block(
74///         Actions::builder()
75///             .block_id("bulk-actions")
76///             .element(
77///                 Button::builder()
78///                     .text(
79///                         Text::<Plain>::builder()
80///                             .text("Confirm All")
81///                             .emoji(true)
82///                             .build()?
83///                     )
84///                     .primary()
85///                     .action_id("bulk_confirm")
86///                     .build()?
87///             )
88///             .element(
89///                 Button::builder()
90///                     .text(
91///                         Text::<Plain>::builder()
92///                             .text("Cancel")
93///                             .emoji(true)
94///                             .build()?
95///                     )
96///                     .action_id("bulk_cancel")
97///                     .build()?
98///             )
99///             .build()?
100///     )
101///     .build()?;
102///
103/// let expected = serde_json::json!({
104///     "type": "container",
105///     "block_id": "bkb_container_bulk_update",
106///     "title": {
107///         "type": "plain_text",
108///         "text": "Bulk update: 2 records selected"
109///     },
110///     "subtitle": {
111///         "type": "plain_text",
112///         "text": "Review changes before confirming"
113///     },
114///     "is_collapsible": true,
115///     "child_blocks": [
116///         {
117///             "type": "section",
118///             "block_id": "record-row-1",
119///             "text": {
120///                 "type": "mrkdwn",
121///                 "text": "*DCW-1024*\nStatus: Open → Closed\nAssignee: @princessdonut → @carl"
122///             }
123///         },
124///         {
125///             "type": "divider",
126///             "block_id": "bulk-div-1"
127///         },
128///         {
129///             "type": "section",
130///             "block_id": "record-row-2",
131///             "text": {
132///                 "type": "mrkdwn",
133///                 "text": "*DCW-1025*\nStatus: In Progress → Closed\nAssignee: @mordecai → @carl"
134///             }
135///         },
136///         {
137///             "type": "divider",
138///             "block_id": "bulk-div-2"
139///         },
140///         {
141///             "type": "context",
142///             "block_id": "bulk-status-bar",
143///             "elements": [
144///                 {
145///                     "type": "mrkdwn",
146///                     "text": ":white_check_mark: 2 records will be updated • Status → Closed • Assignee → @carl"
147///                 }
148///             ]
149///         },
150///         {
151///             "type": "actions",
152///             "block_id": "bulk-actions",
153///             "elements": [
154///                 {
155///                     "type": "button",
156///                     "text": {
157///                         "type": "plain_text",
158///                         "text": "Confirm All",
159///                         "emoji": true
160///                     },
161///                     "style": "primary",
162///                     "action_id": "bulk_confirm"
163///                 },
164///                 {
165///                     "type": "button",
166///                     "text": {
167///                         "type": "plain_text",
168///                         "text": "Cancel",
169///                         "emoji": true
170///                     },
171///                     "action_id": "bulk_cancel"
172///                 }
173///             ]
174///         }
175///     ]
176/// });
177///
178/// let json = serde_json::to_value(container).unwrap();
179///
180/// assert_eq!(json, expected);
181/// #     Ok(())
182/// # }
183/// # fn main() {
184/// #     try_main().unwrap()
185/// # }
186/// ```
187#[derive(Debug, Clone, Serialize, PartialEq, Builder)]
188#[serde(tag = "type", rename = "container")]
189pub struct Container {
190    #[builder(validate("required", "text_object::max_150"))]
191    pub(crate) title: Option<Text<Plain>>,
192
193    #[builder(push_item = "child_block", validate("required", "list::max_item_10"))]
194    pub(crate) child_blocks: Option<Vec<ContainerChildBlock>>,
195
196    #[serde(skip_serializing_if = "Option::is_none")]
197    #[builder(validate("text::max_255"))]
198    pub(crate) block_id: Option<String>,
199
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub(crate) width: Option<ContainerWidth>,
202
203    #[serde(skip_serializing_if = "Option::is_none")]
204    #[builder(validate("text_object::max_150"))]
205    pub(crate) subtitle: Option<TextContent>,
206
207    #[serde(skip_serializing_if = "Option::is_none")]
208    #[builder(validate("image::alt_text_max_2000"))]
209    pub(crate) icon: Option<elements::Image>,
210
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub(crate) is_collapsible: Option<bool>,
213
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub(crate) default_collapsed: Option<bool>,
216}
217
218/// Width of the container. If not specified, the default width is `standard`.
219#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
220#[serde(rename_all = "snake_case")]
221pub enum ContainerWidth {
222    Narrow,
223    Standard,
224    Wide,
225    Full,
226}
227
228/// Child blocks that can be included in a container block.
229#[derive(Debug, Clone, Serialize, PartialEq)]
230#[serde(untagged)]
231pub enum ContainerChildBlock {
232    /// [Actions] block.
233    Actions(Actions),
234
235    /// [Context] block.
236    Context(Context),
237
238    /// [Divider] block.
239    Divider(Divider),
240
241    /// [File] block.
242    File(File),
243
244    /// [Header] block.
245    Header(Header),
246
247    /// [Image] block.
248    Image(Image),
249
250    /// [Input] block.
251    Input(Input),
252
253    /// [RichText] block.
254    RichText(RichText),
255
256    /// [Section] block.
257    Section(Section),
258
259    /// [Table] block.
260    Table(Table),
261
262    /// [Video] block.
263    Video(Video),
264}
265
266macro_rules! impl_from_for_container_child_block {
267    ($($variant:ident),*) => {
268        $(
269            impl From<$variant> for ContainerChildBlock {
270                fn from(block: $variant) -> Self {
271                    ContainerChildBlock::$variant(block)
272                }
273            }
274        )*
275    };
276}
277
278impl_from_for_container_child_block! {
279    Actions,
280    Context,
281    Divider,
282    File,
283    Header,
284    Image,
285    Input,
286    RichText,
287    Section,
288    Table,
289    Video
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::blocks::test_helpers::*;
296    use crate::composition_objects::test_helpers::*;
297    use crate::errors::*;
298
299    #[test]
300    fn it_implements_builder() {
301        let expected = Container {
302            title: Some(plain_text("Container Title")),
303            child_blocks: Some(vec![section("Section Text").into()]),
304            block_id: Some("container_block".into()),
305            width: Some(ContainerWidth::Standard),
306            subtitle: Some(mrkdwn_text("Container Subtitle").into()),
307            icon: Some(image("Icon Alt Text")),
308            is_collapsible: Some(true),
309            default_collapsed: Some(false),
310        };
311
312        let val = Container::builder()
313            .set_title(Some(plain_text("Container Title")))
314            .set_child_blocks(Some(vec![section("Section Text")]))
315            .set_block_id(Some("container_block"))
316            .set_width(Some(ContainerWidth::Standard))
317            .set_subtitle(Some(mrkdwn_text("Container Subtitle")))
318            .set_icon(Some(image("Icon Alt Text")))
319            .set_is_collapsible(Some(true))
320            .set_default_collapsed(Some(false))
321            .build()
322            .unwrap();
323
324        assert_eq!(val, expected);
325
326        let val = Container::builder()
327            .title(plain_text("Container Title"))
328            .child_blocks(vec![section("Section Text")])
329            .block_id("container_block")
330            .width(ContainerWidth::Standard)
331            .subtitle(mrkdwn_text("Container Subtitle"))
332            .icon(image("Icon Alt Text"))
333            .is_collapsible(true)
334            .default_collapsed(false)
335            .build()
336            .unwrap();
337
338        assert_eq!(val, expected);
339    }
340
341    #[test]
342    fn it_implements_push_item_method() {
343        let expected = Container {
344            title: Some(plain_text("Container Title")),
345            child_blocks: Some(vec![
346                header("Header Text").into(),
347                section("Section Text").into(),
348            ]),
349            block_id: None,
350            width: None,
351            subtitle: None,
352            icon: None,
353            is_collapsible: None,
354            default_collapsed: None,
355        };
356
357        let val = Container::builder()
358            .title(plain_text("Container Title"))
359            .child_block(header("Header Text"))
360            .child_block(section("Section Text"))
361            .build()
362            .unwrap();
363
364        assert_eq!(val, expected);
365    }
366
367    #[test]
368    fn it_requires_title_field() {
369        let err = Container::builder()
370            .child_block(section("Section Text"))
371            .build()
372            .unwrap_err();
373        assert_eq!(err.object(), "Container");
374
375        let errors = err.field("title");
376        assert!(errors.includes(ValidationErrorKind::Required));
377    }
378
379    #[test]
380    fn it_requires_title_text_less_than_150_characters() {
381        let long_title = "a".repeat(151);
382        let err = Container::builder()
383            .title(plain_text(&long_title))
384            .child_block(section("Section Text"))
385            .build()
386            .unwrap_err();
387        assert_eq!(err.object(), "Container");
388
389        let errors = err.field("title");
390        assert!(errors.includes(ValidationErrorKind::MaxTextLength(150)));
391    }
392
393    #[test]
394    fn it_requires_child_blocks_field() {
395        let err = Container::builder()
396            .title(plain_text("Container Title"))
397            .build()
398            .unwrap_err();
399        assert_eq!(err.object(), "Container");
400
401        let errors = err.field("child_blocks");
402        assert!(errors.includes(ValidationErrorKind::Required));
403    }
404
405    #[test]
406    fn it_requires_child_blocks_list_size_less_than_10() {
407        let child_blocks: Vec<ContainerChildBlock> = (0..11)
408            .map(|_| section("Section Text").into())
409            .collect();
410        let err = Container::builder()
411            .title(plain_text("Container Title"))
412            .child_blocks(child_blocks)
413            .build()
414            .unwrap_err();
415        assert_eq!(err.object(), "Container");
416
417        let errors = err.field("child_blocks");
418        assert!(errors.includes(ValidationErrorKind::MaxArraySize(10)));
419    }
420
421    #[test]
422    fn it_requires_block_id_less_than_255_characters_long() {
423        let err = Container::builder()
424            .title(plain_text("Container Title"))
425            .child_block(section("Section Text"))
426            .block_id("a".repeat(256))
427            .build()
428            .unwrap_err();
429        assert_eq!(err.object(), "Container");
430
431        let errors = err.field("block_id");
432        assert!(errors.includes(ValidationErrorKind::MaxTextLength(255)));
433    }
434
435    #[test]
436    fn it_requires_subtitle_text_less_than_150_characters() {
437        let long_subtitle = "a".repeat(151);
438        let err = Container::builder()
439            .title(plain_text("Container Title"))
440            .child_block(section("Section Text"))
441            .subtitle(mrkdwn_text(&long_subtitle))
442            .build()
443            .unwrap_err();
444        assert_eq!(err.object(), "Container");
445
446        let errors = err.field("subtitle");
447        assert!(errors.includes(ValidationErrorKind::MaxTextLength(150)));
448    }
449
450    #[test]
451    fn it_requires_icon_alt_text_less_than_2000_characters() {
452        let long_alt_text = "a".repeat(2001);
453        let err = Container::builder()
454            .title(plain_text("Container Title"))
455            .child_block(section("Section Text"))
456            .icon(image(&long_alt_text))
457            .build()
458            .unwrap_err();
459        assert_eq!(err.object(), "Container");
460
461        let errors = err.field("icon");
462        assert!(errors.includes(ValidationErrorKind::MaxAltTextLength(2000)));
463    }
464
465    fn image(alt_text: &str) -> elements::Image {
466        elements::Image {
467            alt_text: Some(alt_text.into()),
468            image_url: Some("http://placekitten.com/700/500".into()),
469            slack_file: None,
470        }
471    }
472}