diff --git a/CHANGELOG.md b/CHANGELOG.md index 7da604b..2f9e5df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [0.7.4][] - 2026-05-15 + +- Support `Alert`, `Card`, `Carousel`, and `Plan` blocks. + ## [0.7.3][] - 2026-03-16 - Support `Taskcard` block. @@ -128,6 +132,7 @@ And the `select menu element` and the `multi-select menu element` are renewed. - pre-release +[0.7.4]: https://github.com/kaicoh/slack-messaging/releases/v0.7.4 [0.7.3]: https://github.com/kaicoh/slack-messaging/releases/v0.7.3 [0.7.2]: https://github.com/kaicoh/slack-messaging/releases/v0.7.2 [0.7.1]: https://github.com/kaicoh/slack-messaging/releases/v0.7.1 diff --git a/slack-messaging-derive/Cargo.toml b/slack-messaging-derive/Cargo.toml index 1a06c33..5961529 100644 --- a/slack-messaging-derive/Cargo.toml +++ b/slack-messaging-derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slack-messaging-derive" -version = "0.7.3" +version = "0.7.4" edition = "2024" authors = ["kaicoh "] keywords = ["slack", "messaging", "webhook"] diff --git a/slack-messaging/Cargo.toml b/slack-messaging/Cargo.toml index 5381879..590c799 100644 --- a/slack-messaging/Cargo.toml +++ b/slack-messaging/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slack-messaging" -version = "0.7.3" +version = "0.7.4" authors = ["kaicoh "] edition = "2024" keywords = ["slack", "messaging", "webhook"] @@ -20,7 +20,7 @@ paste = "1.0" regex = "1.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -slack-messaging-derive = { version = "0.7.3", path = "../slack-messaging-derive" } +slack-messaging-derive = { version = "0.7.4", path = "../slack-messaging-derive" } thiserror = "2.0" [dev-dependencies] diff --git a/slack-messaging/src/blocks/alert.rs b/slack-messaging/src/blocks/alert.rs new file mode 100644 index 0000000..c411cfd --- /dev/null +++ b/slack-messaging/src/blocks/alert.rs @@ -0,0 +1,130 @@ +use crate::composition_objects::TextContent; +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Alert block](https://docs.slack.dev/reference/block-kit/blocks/alert-block) representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/alert-block). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | text | [TextContent] | Yes | N/A | +/// | level | [AlertLevel] | No | N/A | +/// | block_id | String | No | Maximum 255 characters | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::{Alert, AlertLevel}; +/// use slack_messaging::mrkdwn; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let alert = Alert::builder() +/// .text(mrkdwn!("The work is mysterious and important.")?) +/// .level(AlertLevel::Info) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "alert", +/// "text": { +/// "type": "mrkdwn", +/// "text": "The work is mysterious and important." +/// }, +/// "level": "info" +/// }); +/// +/// let json = serde_json::to_value(alert).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[serde(tag = "type", rename = "alert")] +pub struct Alert { + #[builder(validate("required"))] + pub(crate) text: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) level: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text::max_255"))] + pub(crate) block_id: Option, +} + +/// Values that can be set to the `level` field of [Alert]. +#[derive(Debug, Copy, Clone, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AlertLevel { + Default, + Info, + Warning, + Error, + Success, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::composition_objects::test_helpers::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = Alert { + text: Some(plain_text("The work is mysterious and important.").into()), + level: Some(AlertLevel::Info), + block_id: Some("alert_block_0".into()), + }; + + let val = Alert::builder() + .set_text(Some(plain_text("The work is mysterious and important."))) + .set_level(Some(AlertLevel::Info)) + .set_block_id(Some("alert_block_0")) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = Alert::builder() + .text(plain_text("The work is mysterious and important.")) + .level(AlertLevel::Info) + .block_id("alert_block_0") + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_text_field() { + let err = Alert::builder().build().unwrap_err(); + assert_eq!(err.object(), "Alert"); + + let errors = err.field("text"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_block_id_less_than_255_characters_long() { + let err = Alert::builder() + .text(plain_text("The work is mysterious and important.")) + .block_id("a".repeat(256)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Alert"); + + let errors = err.field("block_id"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(255))); + } +} diff --git a/slack-messaging/src/blocks/builders.rs b/slack-messaging/src/blocks/builders.rs index d241dc9..7f9dc1e 100644 --- a/slack-messaging/src/blocks/builders.rs +++ b/slack-messaging/src/blocks/builders.rs @@ -1,4 +1,7 @@ pub use super::actions::ActionsBuilder; +pub use super::alert::AlertBuilder; +pub use super::card::CardBuilder; +pub use super::carousel::CarouselBuilder; pub use super::context::ContextBuilder; pub use super::context_actions::ContextActionsBuilder; pub use super::divider::DividerBuilder; @@ -7,6 +10,7 @@ pub use super::header::HeaderBuilder; pub use super::image::ImageBuilder; pub use super::input::InputBuilder; pub use super::markdown::MarkdownBuilder; +pub use super::plan::PlanBuilder; pub use super::rich_text::RichTextBuilder; pub use super::section::SectionBuilder; pub use super::table::TableBuilder; diff --git a/slack-messaging/src/blocks/card.rs b/slack-messaging/src/blocks/card.rs new file mode 100644 index 0000000..367eb23 --- /dev/null +++ b/slack-messaging/src/blocks/card.rs @@ -0,0 +1,384 @@ +use crate::blocks::elements::{Button, Image}; +use crate::composition_objects::TextContent; +use crate::errors::ValidationErrorKind; +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Card block](https://docs.slack.dev/reference/block-kit/blocks/card-block) representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official documentation](https://docs.slack.dev/reference/block-kit/blocks/card-block). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | block_id | String | No | Maximum 255 characters | +/// | hero_image | [Image] | No | N/A | +/// | icon | [Image] | No | N/A | +/// | title | [TextContent] | No | Maximum 150 characters | +/// | subtitle | [TextContent] | No | Maximum 150 characters | +/// | body | [TextContent] | No | Maximum 200 characters | +/// | actions | Vec<[Button]> | No | N/A | +/// +/// # Validation Across Fields +/// +/// * At least one of `hero_image`, `title`, `actions`, or `body` is required. +/// +/// # Example +/// +/// ``` +/// use slack_messaging::{plain_text, mrkdwn}; +/// use slack_messaging::blocks::Card; +/// use slack_messaging::blocks::elements::{Button, Image}; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let card = Card::builder() +/// .icon( +/// Image::builder() +/// .image_url("https://picsum.photos/36/36") +/// .alt_text("Icon") +/// .build()? +/// ) +/// .title(mrkdwn!("Lumon Industries")?) +/// .subtitle(mrkdwn!("Committed to work-life balance")?) +/// .hero_image( +/// Image::builder() +/// .image_url("https://picsum.photos/400/300") +/// .alt_text("Sample hero image") +/// .build()? +/// ) +/// .body(mrkdwn!("Please enjoy each card equally.")?) +/// .action( +/// Button::builder() +/// .text(plain_text!("Action Button")?) +/// .action_id("button_action") +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "card", +/// "icon": { +/// "type": "image", +/// "image_url": "https://picsum.photos/36/36", +/// "alt_text": "Icon" +/// }, +/// "title": { +/// "type": "mrkdwn", +/// "text": "Lumon Industries" +/// }, +/// "subtitle": { +/// "type": "mrkdwn", +/// "text": "Committed to work-life balance" +/// }, +/// "hero_image": { +/// "type": "image", +/// "image_url": "https://picsum.photos/400/300", +/// "alt_text": "Sample hero image" +/// }, +/// "body": { +/// "type": "mrkdwn", +/// "text": "Please enjoy each card equally." +/// }, +/// "actions": [ +/// { +/// "type": "button", +/// "text": { +/// "type": "plain_text", +/// "text": "Action Button" +/// }, +/// "action_id": "button_action" +/// } +/// ] +/// }); +/// +/// let json = serde_json::to_value(card).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[builder(validate = "validate")] +#[serde(tag = "type", rename = "card")] +pub struct Card { + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text::max_255"))] + pub(crate) block_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) hero_image: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text_object::max_150"))] + pub(crate) title: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text_object::max_150"))] + pub(crate) subtitle: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text_object::max_200"))] + pub(crate) body: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(push_item = "action")] + pub(crate) actions: Option>, +} + +fn validate(val: &Card) -> Vec { + if val.hero_image.is_none() + && val.title.is_none() + && val + .actions + .as_ref() + .is_none_or(|actions| actions.is_empty()) + && val.body.is_none() + { + vec![ValidationErrorKind::AtLeastOneOf4( + "hero_image", + "title", + "actions", + "body", + )] + } else { + vec![] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::composition_objects::test_helpers::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = Card { + block_id: Some("card1".to_string()), + hero_image: Some(Image { + image_url: Some("https://picsum.photos/400/300".into()), + alt_text: Some("Sample hero image".into()), + slack_file: None, + }), + icon: Some(Image { + image_url: Some("https://picsum.photos/36/36".into()), + alt_text: Some("Icon".into()), + slack_file: None, + }), + title: Some(plain_text("Lumon Industries").into()), + subtitle: Some(plain_text("Committed to work-life balance").into()), + body: Some(plain_text("Please enjoy each card equally.").into()), + actions: Some(vec![Button { + text: Some(plain_text("Action Button")), + action_id: Some("button_action".into()), + url: None, + value: None, + style: None, + confirm: None, + accessibility_label: None, + }]), + }; + + let val = Card::builder() + .set_block_id(Some("card1")) + .set_hero_image(Some( + Image::builder() + .image_url("https://picsum.photos/400/300") + .alt_text("Sample hero image") + .build() + .unwrap(), + )) + .set_icon(Some( + Image::builder() + .image_url("https://picsum.photos/36/36") + .alt_text("Icon") + .build() + .unwrap(), + )) + .set_title(Some(plain_text("Lumon Industries"))) + .set_subtitle(Some(plain_text("Committed to work-life balance"))) + .set_body(Some(plain_text("Please enjoy each card equally."))) + .set_actions(Some(vec![ + Button::builder() + .text(plain_text("Action Button")) + .action_id("button_action") + .build() + .unwrap(), + ])) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = Card::builder() + .block_id("card1") + .hero_image( + Image::builder() + .image_url("https://picsum.photos/400/300") + .alt_text("Sample hero image") + .build() + .unwrap(), + ) + .icon( + Image::builder() + .image_url("https://picsum.photos/36/36") + .alt_text("Icon") + .build() + .unwrap(), + ) + .title(plain_text("Lumon Industries")) + .subtitle(plain_text("Committed to work-life balance")) + .body(plain_text("Please enjoy each card equally.")) + .actions(vec![ + Button::builder() + .text(plain_text("Action Button")) + .action_id("button_action") + .build() + .unwrap(), + ]) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = Card { + block_id: None, + hero_image: None, + icon: None, + title: None, + subtitle: None, + body: Some(plain_text("Please enjoy each card equally.").into()), + actions: Some(vec![Button { + text: Some(plain_text("Action Button")), + action_id: Some("button_action".into()), + url: None, + value: None, + style: None, + confirm: None, + accessibility_label: None, + }]), + }; + + let val = Card::builder() + .body(plain_text("Please enjoy each card equally.")) + .action( + Button::builder() + .text(plain_text("Action Button")) + .action_id("button_action") + .build() + .unwrap(), + ) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_title_less_than_150_characters_long() { + let err = Card::builder() + .title(plain_text("a".repeat(151))) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Card"); + + let errors = err.field("title"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(150))); + } + + #[test] + fn it_requires_subtitle_less_than_150_characters_long() { + let err = Card::builder() + .title(plain_text("Valid Title")) + .subtitle(plain_text("a".repeat(151))) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Card"); + + let errors = err.field("subtitle"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(150))); + } + + #[test] + fn it_requires_body_less_than_200_characters_long() { + let err = Card::builder() + .body(plain_text("a".repeat(201))) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Card"); + + let errors = err.field("body"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(200))); + } + + #[test] + fn it_requires_at_least_one_of_hero_image_title_actions_body() { + let err = Card::builder().build().unwrap_err(); + assert_eq!(err.object(), "Card"); + + let errors = err.across_fields(); + assert!(errors.includes(ValidationErrorKind::AtLeastOneOf4( + "hero_image", + "title", + "actions", + "body" + ))); + + let card = Card::builder() + .hero_image( + Image::builder() + .image_url("https://picsum.photos/400/300") + .alt_text("Sample hero image") + .build() + .unwrap(), + ) + .build(); + assert!(card.is_ok()); + + let card = Card::builder() + .title(plain_text("Lumon Industries")) + .build(); + assert!(card.is_ok()); + + let card = Card::builder() + .body(plain_text("Please enjoy each card equally.")) + .build(); + assert!(card.is_ok()); + + let card = Card::builder() + .action( + Button::builder() + .text(plain_text("Action Button")) + .action_id("button_action") + .build() + .unwrap(), + ) + .build(); + assert!(card.is_ok()); + } + + #[test] + fn it_requires_block_id_less_than_255_characters_long() { + let err = Card::builder() + .block_id("a".repeat(256)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Card"); + + let errors = err.field("block_id"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(255))); + } +} diff --git a/slack-messaging/src/blocks/carousel.rs b/slack-messaging/src/blocks/carousel.rs new file mode 100644 index 0000000..6d5f7ff --- /dev/null +++ b/slack-messaging/src/blocks/carousel.rs @@ -0,0 +1,359 @@ +use crate::blocks::Card; +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Carousel block](https://docs.slack.dev/reference/block-kit/blocks/carousel-block) +/// representation +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/carousel-block). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | elements | Vec<[Card]> | Yes | Must contain as least 1 card and at most 10 cards. | +/// | block_id | String | No | Must be 255 characters or less. | +/// +/// # Examples +/// +/// ``` +/// use slack_messaging::{plain_text, mrkdwn}; +/// use slack_messaging::blocks::{Card, Carousel}; +/// use slack_messaging::blocks::elements::{Button, Image}; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let carousel = Carousel::builder() +/// .element( +/// Card::builder() +/// .block_id("carousel-card-1") +/// .icon( +/// Image::builder() +/// .image_url("https://picsum.photos/36/36") +/// .alt_text("Icon") +/// .build()? +/// ) +/// .title(mrkdwn!("MDR")?) +/// .subtitle(mrkdwn!("Refining data files")?) +/// .hero_image( +/// Image::builder() +/// .image_url("https://picsum.photos/400/300") +/// .alt_text("Sample hero image") +/// .build()? +/// ) +/// .body(mrkdwn!("Blue badge required to gain access.")?) +/// .action( +/// Button::builder() +/// .text(plain_text!("Action Button")?) +/// .action_id("button_action_1") +/// .build()? +/// ) +/// .build()? +/// ) +/// .element( +/// Card::builder() +/// .block_id("carousel-card-2") +/// .icon( +/// Image::builder() +/// .image_url("https://picsum.photos/36/36") +/// .alt_text("Icon") +/// .build()? +/// ) +/// .title(mrkdwn!("O&D")?) +/// .subtitle(mrkdwn!("Storage, maintenance, and rotation of art pieces")?) +/// .hero_image( +/// Image::builder() +/// .image_url("https://picsum.photos/400/300") +/// .alt_text("Sample hero image") +/// .build()? +/// ) +/// .body(mrkdwn!("Green badge required to gain access.")?) +/// .action( +/// Button::builder() +/// .text(plain_text!("Action Button")?) +/// .action_id("button_action_2") +/// .build()? +/// ) +/// .build()? +/// ) +/// .element( +/// Card::builder() +/// .block_id("carousel-card-3") +/// .icon( +/// Image::builder() +/// .image_url("https://picsum.photos/36/36") +/// .alt_text("Icon") +/// .build()? +/// ) +/// .title(mrkdwn!("Wellness Center")?) +/// .subtitle(mrkdwn!("Wellness sessions")?) +/// .hero_image( +/// Image::builder() +/// .image_url("https://picsum.photos/400/300") +/// .alt_text("Sample hero image") +/// .build()? +/// ) +/// .body(mrkdwn!("Please take a seat in the waiting room until called.")?) +/// .action( +/// Button::builder() +/// .text(plain_text!("Action Button")?) +/// .action_id("button_action_3") +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "carousel", +/// "elements": [ +/// { +/// "type": "card", +/// "block_id": "carousel-card-1", +/// "icon": { +/// "type": "image", +/// "image_url": "https://picsum.photos/36/36", +/// "alt_text": "Icon" +/// }, +/// "title": { +/// "type": "mrkdwn", +/// "text": "MDR" +/// }, +/// "subtitle": { +/// "type": "mrkdwn", +/// "text": "Refining data files" +/// }, +/// "hero_image": { +/// "type": "image", +/// "image_url": "https://picsum.photos/400/300", +/// "alt_text": "Sample hero image" +/// }, +/// "body": { +/// "type": "mrkdwn", +/// "text": "Blue badge required to gain access." +/// }, +/// "actions": [ +/// { +/// "type": "button", +/// "text": { +/// "type": "plain_text", +/// "text": "Action Button" +/// }, +/// "action_id": "button_action_1" +/// } +/// ] +/// }, +/// { +/// "type": "card", +/// "block_id": "carousel-card-2", +/// "icon": { +/// "type": "image", +/// "image_url": "https://picsum.photos/36/36", +/// "alt_text": "Icon" +/// }, +/// "title": { +/// "type": "mrkdwn", +/// "text": "O&D" +/// }, +/// "subtitle": { +/// "type": "mrkdwn", +/// "text": "Storage, maintenance, and rotation of art pieces" +/// }, +/// "hero_image": { +/// "type": "image", +/// "image_url": "https://picsum.photos/400/300", +/// "alt_text": "Sample hero image" +/// }, +/// "body": { +/// "type": "mrkdwn", +/// "text": "Green badge required to gain access." +/// }, +/// "actions": [ +/// { +/// "type": "button", +/// "text": { +/// "type": "plain_text", +/// "text": "Action Button" +/// }, +/// "action_id": "button_action_2" +/// } +/// ] +/// }, +/// { +/// "type": "card", +/// "block_id": "carousel-card-3", +/// "icon": { +/// "type": "image", +/// "image_url": "https://picsum.photos/36/36", +/// "alt_text": "Icon" +/// }, +/// "title": { +/// "type": "mrkdwn", +/// "text": "Wellness Center" +/// }, +/// "subtitle": { +/// "type": "mrkdwn", +/// "text": "Wellness sessions" +/// }, +/// "hero_image": { +/// "type": "image", +/// "image_url": "https://picsum.photos/400/300", +/// "alt_text": "Sample hero image" +/// }, +/// "body": { +/// "type": "mrkdwn", +/// "text": "Please take a seat in the waiting room until called." +/// }, +/// "actions": [ +/// { +/// "type": "button", +/// "text": { +/// "type": "plain_text", +/// "text": "Action Button" +/// }, +/// "action_id": "button_action_3" +/// } +/// ] +/// } +/// ] +/// }); +/// +/// let json = serde_json::to_value(carousel).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, Builder, PartialEq)] +#[serde(tag = "type", rename = "carousel")] +pub struct Carousel { + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text::max_255"))] + pub(crate) block_id: Option, + + #[builder( + push_item = "element", + validate("required", "list::not_empty", "list::max_item_10") + )] + pub(crate) elements: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::composition_objects::test_helpers::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = Carousel { + block_id: Some("carousel_0".into()), + elements: Some(vec![card("Card 1"), card("Card 2")]), + }; + + let val = Carousel::builder() + .set_block_id(Some("carousel_0")) + .set_elements(Some(vec![card("Card 1"), card("Card 2")])) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = Carousel::builder() + .block_id("carousel_0") + .elements(vec![card("Card 1"), card("Card 2")]) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = Carousel { + block_id: None, + elements: Some(vec![card("Card 1"), card("Card 2")]), + }; + + let val = Carousel::builder() + .element(card("Card 1")) + .element(card("Card 2")) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_block_id_less_than_255_characters_long() { + let err = Carousel::builder() + .block_id("a".repeat(256)) + .element(card("Card 1")) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Carousel"); + + let errors = err.field("block_id"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(255))); + } + + #[test] + fn it_requires_elements_field() { + let err = Carousel::builder().build().unwrap_err(); + assert_eq!(err.object(), "Carousel"); + + let errors = err.field("elements"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_elements_to_have_at_least_1_item() { + let err = Carousel::builder().elements(vec![]).build().unwrap_err(); + assert_eq!(err.object(), "Carousel"); + + let errors = err.field("elements"); + assert!(errors.includes(ValidationErrorKind::EmptyArray)); + } + + #[test] + fn it_requires_elements_to_have_at_most_10_items() { + let err = Carousel::builder() + .elements(vec![ + card("Card 1"), + card("Card 2"), + card("Card 3"), + card("Card 4"), + card("Card 5"), + card("Card 6"), + card("Card 7"), + card("Card 8"), + card("Card 9"), + card("Card 10"), + card("Card 11"), + ]) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Carousel"); + + let errors = err.field("elements"); + assert!(errors.includes(ValidationErrorKind::MaxArraySize(10))); + } + + fn card(title: &str) -> Card { + Card { + block_id: None, + icon: None, + title: Some(plain_text(title).into()), + subtitle: None, + hero_image: None, + body: None, + actions: None, + } + } +} diff --git a/slack-messaging/src/blocks/divider.rs b/slack-messaging/src/blocks/divider.rs index 4712591..f14ba07 100644 --- a/slack-messaging/src/blocks/divider.rs +++ b/slack-messaging/src/blocks/divider.rs @@ -1,7 +1,7 @@ use crate::validators::*; -use slack_messaging_derive::Builder; use serde::Serialize; +use slack_messaging_derive::Builder; /// [Divider block](https://docs.slack.dev/reference/block-kit/blocks/divider-block) /// representation. diff --git a/slack-messaging/src/blocks/mod.rs b/slack-messaging/src/blocks/mod.rs index 94cc379..285bb57 100644 --- a/slack-messaging/src/blocks/mod.rs +++ b/slack-messaging/src/blocks/mod.rs @@ -10,6 +10,9 @@ pub mod rich_text; pub mod table; mod actions; +mod alert; +mod card; +mod carousel; mod context; mod context_actions; mod divider; @@ -18,11 +21,15 @@ mod header; mod image; mod input; mod markdown; +mod plan; mod section; mod task_card; mod video; pub use actions::{Actions, ActionsElement}; +pub use alert::{Alert, AlertLevel}; +pub use card::Card; +pub use carousel::Carousel; pub use context::{Context, ContextElement}; pub use context_actions::{ContextActions, ContextActionsElement}; pub use divider::Divider; @@ -31,6 +38,7 @@ pub use header::Header; pub use image::Image; pub use input::{Input, InputElement}; pub use markdown::Markdown; +pub use plan::Plan; pub use rich_text::RichText; pub use section::{Accessory, Section}; pub use table::Table; @@ -44,6 +52,17 @@ pub enum Block { /// [Actions block](https://docs.slack.dev/reference/block-kit/blocks/actions-block) representation Actions(Box), + /// [Alert block](https://docs.slack.dev/reference/block-kit/blocks/alert-block) representation + Alert(Box), + + /// [Card block](https://docs.slack.dev/reference/block-kit/blocks/card-block) + /// representation + Card(Box), + + /// [Carousel block](https://docs.slack.dev/reference/block-kit/blocks/carousel-block) + /// representation + Carousel(Box), + /// [Context block](https://docs.slack.dev/reference/block-kit/blocks/context-block) representation Context(Box), @@ -68,6 +87,9 @@ pub enum Block { /// [Markdown block](https://docs.slack.dev/reference/block-kit/blocks/markdown-block) representation Markdown(Box), + /// [Plan block](https://docs.slack.dev/reference/block-kit/blocks/plan-block) representation + Plan(Box), + /// [Rich text block](https://docs.slack.dev/reference/block-kit/blocks/rich-text-block) representation RichText(Box), @@ -95,6 +117,9 @@ macro_rules! block_from { block_from! { Actions, + Alert, + Card, + Carousel, Context, ContextActions, Divider, @@ -103,6 +128,7 @@ block_from! { Image, Input, Markdown, + Plan, RichText, Section, Table, @@ -139,4 +165,16 @@ pub mod test_helpers { elements: Some(vec![rich_text_helper::section(vec![el_text("foo")]).into()]), } } + + pub fn task_card() -> TaskCard { + TaskCard { + task_id: Some("task_0".into()), + title: Some("Fetching weather data".into()), + status: Some(TaskStatus::Pending), + output: None, + details: None, + sources: None, + block_id: None, + } + } } diff --git a/slack-messaging/src/blocks/plan.rs b/slack-messaging/src/blocks/plan.rs new file mode 100644 index 0000000..0b9ada0 --- /dev/null +++ b/slack-messaging/src/blocks/plan.rs @@ -0,0 +1,260 @@ +use crate::blocks::TaskCard; +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Plan block](https://docs.slack.dev/reference/block-kit/blocks/plan-block) representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official documentation](https://docs.slack.dev/reference/block-kit/blocks/plan-block). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | title | String | Yes | N/A | +/// | block_id | String | No | Maximum 255 characters | +/// | tasks | Vec<[TaskCard]> | No | N/A | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::{Plan, RichText, TaskCard, TaskStatus}; +/// use slack_messaging::blocks::rich_text::RichTextSection; +/// use slack_messaging::blocks::rich_text::types::RichTextElementText; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let plan = Plan::builder() +/// .title("Thinking completed") +/// .task( +/// TaskCard::builder() +/// .task_id("call_001") +/// .title("Fetched user profile information") +/// .status(TaskStatus::InProgress) +/// .details( +/// RichText::builder() +/// .block_id("viMWO") +/// .element( +/// RichTextSection::builder() +/// .element( +/// RichTextElementText::builder() +/// .text("Searched database...") +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()? +/// ) +/// .output( +/// RichText::builder() +/// .block_id("viMWO") +/// .element( +/// RichTextSection::builder() +/// .element( +/// RichTextElementText::builder() +/// .text("Profile data loaded") +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()? +/// ) +/// .task( +/// TaskCard::builder() +/// .task_id("call_002") +/// .title("Checked user permissions") +/// .status(TaskStatus::Pending) +/// .build()? +/// ) +/// .task( +/// TaskCard::builder() +/// .task_id("call_003") +/// .title("Generated comprehensive user report") +/// .status(TaskStatus::Complete) +/// .output( +/// RichText::builder() +/// .block_id("crsk") +/// .element( +/// RichTextSection::builder() +/// .element( +/// RichTextElementText::builder() +/// .text("15 data points compiled") +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "plan", +/// "title": "Thinking completed", +/// "tasks": [ +/// { +/// "type": "task_card", +/// "task_id": "call_001", +/// "title": "Fetched user profile information", +/// "status": "in_progress", +/// "details": { +/// "type": "rich_text", +/// "block_id": "viMWO", +/// "elements": [ +/// { +/// "type": "rich_text_section", +/// "elements": [ +/// { +/// "type": "text", +/// "text": "Searched database..." +/// } +/// ] +/// } +/// ] +/// }, +/// "output": { +/// "type": "rich_text", +/// "block_id": "viMWO", +/// "elements": [ +/// { +/// "type": "rich_text_section", +/// "elements": [ +/// { +/// "type": "text", +/// "text": "Profile data loaded" +/// } +/// ] +/// } +/// ] +/// } +/// }, +/// { +/// "type": "task_card", +/// "task_id": "call_002", +/// "title": "Checked user permissions", +/// "status": "pending" +/// }, +/// { +/// "type": "task_card", +/// "task_id": "call_003", +/// "title": "Generated comprehensive user report", +/// "status": "complete", +/// "output": { +/// "type": "rich_text", +/// "block_id": "crsk", +/// "elements": [ +/// { +/// "type": "rich_text_section", +/// "elements": [ +/// { +/// "type": "text", +/// "text": "15 data points compiled" +/// } +/// ] +/// } +/// ] +/// } +/// } +/// ] +/// }); +/// +/// let json = serde_json::to_value(plan).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +///``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[serde(tag = "type", rename = "plan")] +pub struct Plan { + #[builder(validate("required"))] + pub(crate) title: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text::max_255"))] + pub(crate) block_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(push_item = "task")] + pub(crate) tasks: Option>, +} + +#[cfg(test)] +mod tests { + use super::super::test_helpers::*; + use super::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = Plan { + title: Some("Thinking completed".into()), + block_id: Some("block_0".into()), + tasks: Some(vec![task_card()]), + }; + + let val = Plan::builder() + .set_title(Some("Thinking completed")) + .set_block_id(Some("block_0")) + .set_tasks(Some(vec![task_card()])) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = Plan::builder() + .title("Thinking completed") + .block_id("block_0") + .tasks(vec![task_card()]) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = Plan { + title: Some("Thinking completed".into()), + block_id: None, + tasks: Some(vec![task_card()]), + }; + + let val = Plan::builder() + .title("Thinking completed") + .task(task_card()) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_title_field() { + let err = Plan::builder().build().unwrap_err(); + assert_eq!(err.object(), "Plan"); + + let errors = err.field("title"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_block_id_less_than_255_characters_long() { + let err = Plan::builder() + .title("Thinking completed") + .block_id("b".repeat(256)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Plan"); + + let errors = err.field("block_id"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(255))); + } +} diff --git a/slack-messaging/src/errors.rs b/slack-messaging/src/errors.rs index dc28177..0fa60e4 100644 --- a/slack-messaging/src/errors.rs +++ b/slack-messaging/src/errors.rs @@ -44,6 +44,9 @@ pub enum ValidationErrorKind { #[error("required either {0} or {1}")] EitherRequired(&'static str, &'static str), + #[error("at least one of {0}, {1}, {2}, or {3} is required")] + AtLeastOneOf4(&'static str, &'static str, &'static str, &'static str), + /// At least one field is required but none is provided. #[error("required at least one field")] NoFieldProvided,