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}