From e4213d7d98da851ee9c9848568aecd1d695f3e56 Mon Sep 17 00:00:00 2001 From: Kanji Tanaka Date: Sat, 4 Jul 2026 06:16:41 +0900 Subject: [PATCH 1/5] Bump up to v0.7.8 --- slack-messaging-derive/Cargo.toml | 2 +- slack-messaging/Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/slack-messaging-derive/Cargo.toml b/slack-messaging-derive/Cargo.toml index 6beffc5..f93bdc7 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.7" +version = "0.7.8" edition = "2024" authors = ["kaicoh "] keywords = ["slack", "messaging", "webhook"] diff --git a/slack-messaging/Cargo.toml b/slack-messaging/Cargo.toml index b923574..8c1e56c 100644 --- a/slack-messaging/Cargo.toml +++ b/slack-messaging/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slack-messaging" -version = "0.7.7" +version = "0.7.8" 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.7", path = "../slack-messaging-derive" } +slack-messaging-derive = { version = "0.7.8", path = "../slack-messaging-derive" } thiserror = "2.0" [dev-dependencies] From edb2e2fb6438299479e699dd021bae2074ad6c5c Mon Sep 17 00:00:00 2001 From: Kanji Tanaka Date: Sat, 4 Jul 2026 11:59:25 +0900 Subject: [PATCH 2/5] Add Container block --- slack-messaging/src/blocks/builders.rs | 1 + slack-messaging/src/blocks/container.rs | 472 ++++++++++++++++++++++++ slack-messaging/src/blocks/mod.rs | 2 + slack-messaging/src/errors.rs | 4 + slack-messaging/src/validators/image.rs | 71 ++++ slack-messaging/src/validators/mod.rs | 1 + 6 files changed, 551 insertions(+) create mode 100644 slack-messaging/src/blocks/container.rs create mode 100644 slack-messaging/src/validators/image.rs diff --git a/slack-messaging/src/blocks/builders.rs b/slack-messaging/src/blocks/builders.rs index 39e2153..57e58e7 100644 --- a/slack-messaging/src/blocks/builders.rs +++ b/slack-messaging/src/blocks/builders.rs @@ -2,6 +2,7 @@ pub use super::actions::ActionsBuilder; pub use super::alert::AlertBuilder; pub use super::card::CardBuilder; pub use super::carousel::CarouselBuilder; +pub use super::container::ContainerBuilder; pub use super::context::ContextBuilder; pub use super::context_actions::ContextActionsBuilder; pub use super::data_table::DataTableBuilder; diff --git a/slack-messaging/src/blocks/container.rs b/slack-messaging/src/blocks/container.rs new file mode 100644 index 0000000..93a85fd --- /dev/null +++ b/slack-messaging/src/blocks/container.rs @@ -0,0 +1,472 @@ +use crate::blocks::{elements, Actions, Context, Divider, File, Header, Image, Input, RichText, Section, Table, Video}; +use crate::composition_objects::{Plain, Text, TextContent}; +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Container block](https://docs.slack.dev/reference/block-kit/blocks/container-block) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/container-block). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | title | [Text] | Yes | Maximum 150 characters | +/// | child_blocks | Vec<[ContainerChildBlock]> | Yes | Maximum 10 items | +/// | block_id | String | No | Maximum 255 characters | +/// | width | [ContainerWidth] | No | N/A | +/// | subtitle | [TextContent] | No | Maximum 150 characters | +/// | icon | [elements::Image] | No | Maximum 2000 characters | +/// | is_collapsible | bool | No | N/A | +/// | default_collapsed | bool | No | N/A | +/// +/// # Example +/// +/// The following is reproduction of [the sample container +/// block](https://docs.slack.dev/reference/block-kit/blocks/container-block#examples). +/// +/// ``` +/// use slack_messaging::{plain_text, mrkdwn}; +/// use slack_messaging::blocks::*; +/// use slack_messaging::blocks::elements::Button; +/// use slack_messaging::composition_objects::{Plain, Text}; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let container = Container::builder() +/// .block_id("bkb_container_bulk_update") +/// .title(plain_text!("Bulk update: 2 records selected")?) +/// .subtitle(plain_text!("Review changes before confirming")?) +/// .is_collapsible(true) +/// .child_block( +/// Section::builder() +/// .block_id("record-row-1") +/// .text(mrkdwn!("*DCW-1024*\nStatus: Open → Closed\nAssignee: @princessdonut → @carl")?) +/// .build()? +/// ) +/// .child_block( +/// Divider::builder() +/// .block_id("bulk-div-1") +/// .build()? +/// ) +/// .child_block( +/// Section::builder() +/// .block_id("record-row-2") +/// .text(mrkdwn!("*DCW-1025*\nStatus: In Progress → Closed\nAssignee: @mordecai → @carl")?) +/// .build()? +/// ) +/// .child_block( +/// Divider::builder() +/// .block_id("bulk-div-2") +/// .build()? +/// ) +/// .child_block( +/// Context::builder() +/// .block_id("bulk-status-bar") +/// .element(mrkdwn!(":white_check_mark: 2 records will be updated • Status → Closed • Assignee → @carl")?) +/// .build()? +/// ) +/// .child_block( +/// Actions::builder() +/// .block_id("bulk-actions") +/// .element( +/// Button::builder() +/// .text( +/// Text::::builder() +/// .text("Confirm All") +/// .emoji(true) +/// .build()? +/// ) +/// .primary() +/// .action_id("bulk_confirm") +/// .build()? +/// ) +/// .element( +/// Button::builder() +/// .text( +/// Text::::builder() +/// .text("Cancel") +/// .emoji(true) +/// .build()? +/// ) +/// .action_id("bulk_cancel") +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "container", +/// "block_id": "bkb_container_bulk_update", +/// "title": { +/// "type": "plain_text", +/// "text": "Bulk update: 2 records selected" +/// }, +/// "subtitle": { +/// "type": "plain_text", +/// "text": "Review changes before confirming" +/// }, +/// "is_collapsible": true, +/// "child_blocks": [ +/// { +/// "type": "section", +/// "block_id": "record-row-1", +/// "text": { +/// "type": "mrkdwn", +/// "text": "*DCW-1024*\nStatus: Open → Closed\nAssignee: @princessdonut → @carl" +/// } +/// }, +/// { +/// "type": "divider", +/// "block_id": "bulk-div-1" +/// }, +/// { +/// "type": "section", +/// "block_id": "record-row-2", +/// "text": { +/// "type": "mrkdwn", +/// "text": "*DCW-1025*\nStatus: In Progress → Closed\nAssignee: @mordecai → @carl" +/// } +/// }, +/// { +/// "type": "divider", +/// "block_id": "bulk-div-2" +/// }, +/// { +/// "type": "context", +/// "block_id": "bulk-status-bar", +/// "elements": [ +/// { +/// "type": "mrkdwn", +/// "text": ":white_check_mark: 2 records will be updated • Status → Closed • Assignee → @carl" +/// } +/// ] +/// }, +/// { +/// "type": "actions", +/// "block_id": "bulk-actions", +/// "elements": [ +/// { +/// "type": "button", +/// "text": { +/// "type": "plain_text", +/// "text": "Confirm All", +/// "emoji": true +/// }, +/// "style": "primary", +/// "action_id": "bulk_confirm" +/// }, +/// { +/// "type": "button", +/// "text": { +/// "type": "plain_text", +/// "text": "Cancel", +/// "emoji": true +/// }, +/// "action_id": "bulk_cancel" +/// } +/// ] +/// } +/// ] +/// }); +/// +/// let json = serde_json::to_value(container).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[serde(tag = "type", rename = "container")] +pub struct Container { + #[builder(validate("required", "text_object::max_150"))] + pub(crate) title: Option>, + + #[builder(push_item = "child_block", validate("required", "list::max_item_10"))] + pub(crate) child_blocks: 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")] + pub(crate) width: 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("image::alt_text_max_2000"))] + pub(crate) icon: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) is_collapsible: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) default_collapsed: Option, +} + +/// Width of the container. If not specified, the default width is `standard`. +#[derive(Debug, Clone, Copy, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ContainerWidth { + Narrow, + Standard, + Wide, + Full, +} + +/// Child blocks that can be included in a container block. +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum ContainerChildBlock { + /// [Actions] block. + Actions(Actions), + + /// [Context] block. + Context(Context), + + /// [Divider] block. + Divider(Divider), + + /// [File] block. + File(File), + + /// [Header] block. + Header(Header), + + /// [Image] block. + Image(Image), + + /// [Input] block. + Input(Input), + + /// [RichText] block. + RichText(RichText), + + /// [Section] block. + Section(Section), + + /// [Table] block. + Table(Table), + + /// [Video] block. + Video(Video), +} + +macro_rules! impl_from_for_container_child_block { + ($($variant:ident),*) => { + $( + impl From<$variant> for ContainerChildBlock { + fn from(block: $variant) -> Self { + ContainerChildBlock::$variant(block) + } + } + )* + }; +} + +impl_from_for_container_child_block! { + Actions, + Context, + Divider, + File, + Header, + Image, + Input, + RichText, + Section, + Table, + Video +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blocks::test_helpers::*; + use crate::composition_objects::test_helpers::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = Container { + title: Some(plain_text("Container Title")), + child_blocks: Some(vec![section("Section Text").into()]), + block_id: Some("container_block".into()), + width: Some(ContainerWidth::Standard), + subtitle: Some(mrkdwn_text("Container Subtitle").into()), + icon: Some(image("Icon Alt Text")), + is_collapsible: Some(true), + default_collapsed: Some(false), + }; + + let val = Container::builder() + .set_title(Some(plain_text("Container Title"))) + .set_child_blocks(Some(vec![section("Section Text")])) + .set_block_id(Some("container_block")) + .set_width(Some(ContainerWidth::Standard)) + .set_subtitle(Some(mrkdwn_text("Container Subtitle"))) + .set_icon(Some(image("Icon Alt Text"))) + .set_is_collapsible(Some(true)) + .set_default_collapsed(Some(false)) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = Container::builder() + .title(plain_text("Container Title")) + .child_blocks(vec![section("Section Text")]) + .block_id("container_block") + .width(ContainerWidth::Standard) + .subtitle(mrkdwn_text("Container Subtitle")) + .icon(image("Icon Alt Text")) + .is_collapsible(true) + .default_collapsed(false) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = Container { + title: Some(plain_text("Container Title")), + child_blocks: Some(vec![ + header("Header Text").into(), + section("Section Text").into(), + ]), + block_id: None, + width: None, + subtitle: None, + icon: None, + is_collapsible: None, + default_collapsed: None, + }; + + let val = Container::builder() + .title(plain_text("Container Title")) + .child_block(header("Header Text")) + .child_block(section("Section Text")) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_title_field() { + let err = Container::builder() + .child_block(section("Section Text")) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Container"); + + let errors = err.field("title"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_title_text_less_than_150_characters() { + let long_title = "a".repeat(151); + let err = Container::builder() + .title(plain_text(&long_title)) + .child_block(section("Section Text")) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Container"); + + let errors = err.field("title"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(150))); + } + + #[test] + fn it_requires_child_blocks_field() { + let err = Container::builder() + .title(plain_text("Container Title")) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Container"); + + let errors = err.field("child_blocks"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_child_blocks_list_size_less_than_10() { + let child_blocks: Vec = (0..11) + .map(|_| section("Section Text").into()) + .collect(); + let err = Container::builder() + .title(plain_text("Container Title")) + .child_blocks(child_blocks) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Container"); + + let errors = err.field("child_blocks"); + assert!(errors.includes(ValidationErrorKind::MaxArraySize(10))); + } + + #[test] + fn it_requires_block_id_less_than_255_characters_long() { + let err = Container::builder() + .title(plain_text("Container Title")) + .child_block(section("Section Text")) + .block_id("a".repeat(256)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Container"); + + let errors = err.field("block_id"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(255))); + } + + #[test] + fn it_requires_subtitle_text_less_than_150_characters() { + let long_subtitle = "a".repeat(151); + let err = Container::builder() + .title(plain_text("Container Title")) + .child_block(section("Section Text")) + .subtitle(mrkdwn_text(&long_subtitle)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Container"); + + let errors = err.field("subtitle"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(150))); + } + + #[test] + fn it_requires_icon_alt_text_less_than_2000_characters() { + let long_alt_text = "a".repeat(2001); + let err = Container::builder() + .title(plain_text("Container Title")) + .child_block(section("Section Text")) + .icon(image(&long_alt_text)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Container"); + + let errors = err.field("icon"); + assert!(errors.includes(ValidationErrorKind::MaxAltTextLength(2000))); + } + + fn image(alt_text: &str) -> elements::Image { + elements::Image { + alt_text: Some(alt_text.into()), + image_url: Some("http://placekitten.com/700/500".into()), + slack_file: None, + } + } +} diff --git a/slack-messaging/src/blocks/mod.rs b/slack-messaging/src/blocks/mod.rs index 06e639b..209944a 100644 --- a/slack-messaging/src/blocks/mod.rs +++ b/slack-messaging/src/blocks/mod.rs @@ -17,6 +17,7 @@ mod actions; mod alert; mod card; mod carousel; +mod container; mod context; mod context_actions; mod divider; @@ -34,6 +35,7 @@ pub use actions::{Actions, ActionsElement}; pub use alert::{Alert, AlertLevel}; pub use card::Card; pub use carousel::Carousel; +pub use container::{Container, ContainerChildBlock, ContainerWidth}; pub use context::{Context, ContextElement}; pub use context_actions::{ContextActions, ContextActionsElement}; pub use data_table::DataTable; diff --git a/slack-messaging/src/errors.rs b/slack-messaging/src/errors.rs index eecae52..5ef2558 100644 --- a/slack-messaging/src/errors.rs +++ b/slack-messaging/src/errors.rs @@ -12,6 +12,10 @@ pub enum ValidationErrorKind { #[error("max text length `{0}` characters")] MaxTextLength(usize), + /// Field exceeds maximum text length. + #[error("max alt text length `{0}` characters")] + MaxAltTextLength(usize), + /// Field does not meet minimum text length. #[error("min text length `{0}` characters")] MinTextLength(usize), diff --git a/slack-messaging/src/validators/image.rs b/slack-messaging/src/validators/image.rs new file mode 100644 index 0000000..c50e9cd --- /dev/null +++ b/slack-messaging/src/validators/image.rs @@ -0,0 +1,71 @@ +use super::*; +use crate::blocks::elements::Image; + +use paste::paste; + +fn inner_validator(mut value: Value, error: ValidationErrorKind, f: F) -> Value +where + F: Fn(&Image) -> bool, +{ + if value.inner_ref().is_some_and(f) { + value.push(error); + } + value +} + +fn alt_text_max(max: usize, value: Value) -> Value { + inner_validator(value, ValidationErrorKind::MaxAltTextLength(max), |v| { + v.alt_text.as_ref().is_some_and(|t| t.len() > max) + }) +} + +macro_rules! impl_alt_text_max { + ($($e:expr),*) => { + paste! { + $( + pub(crate) fn [](value: Value) -> Value { + alt_text_max($e, value) + } + )* + } + } +} + +impl_alt_text_max!(2000); + +#[cfg(test)] +mod tests { + use super::*; + + mod fn_alt_text_max_2000 { + use super::*; + + #[test] + fn it_sets_error_if_the_alt_text_is_too_long() { + let result = test("a".repeat(2001)); + assert_eq!( + result.errors, + vec![ValidationErrorKind::MaxAltTextLength(2000)] + ); + } + + #[test] + fn it_passes_if_the_alt_text_is_smaller_than_2000() { + let result = test("a".repeat(2000)); + assert!(result.errors.is_empty()); + } + + fn test(alt_text: impl Into) -> Value { + let value = image(alt_text); + alt_text_max_2000(Value::new(Some(value))) + } + + fn image(alt_text: impl Into) -> Image { + Image { + alt_text: Some(alt_text.into()), + image_url: Some("http://placekitten.com/700/500".into()), + slack_file: None, + } + } + } +} diff --git a/slack-messaging/src/validators/mod.rs b/slack-messaging/src/validators/mod.rs index 64a36f9..e33fff7 100644 --- a/slack-messaging/src/validators/mod.rs +++ b/slack-messaging/src/validators/mod.rs @@ -1,6 +1,7 @@ use crate::errors::ValidationErrorKind; use crate::value::Value; +pub(crate) mod image; pub(crate) mod integer; pub(crate) mod list; pub(crate) mod number; From e5cbd25f9a69c74ad76bec13549f9ae5cebdaa98 Mon Sep 17 00:00:00 2001 From: Kanji Tanaka Date: Sat, 4 Jul 2026 12:07:57 +0900 Subject: [PATCH 3/5] Rename HasText trait --- slack-messaging/src/composition_objects/mod.rs | 2 +- slack-messaging/src/composition_objects/option.rs | 12 ++++++------ .../src/composition_objects/option_group.rs | 6 +++--- slack-messaging/src/composition_objects/text.rs | 8 ++++---- slack-messaging/src/validators/list.rs | 4 ++-- slack-messaging/src/validators/text_object.rs | 10 +++++----- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/slack-messaging/src/composition_objects/mod.rs b/slack-messaging/src/composition_objects/mod.rs index 173a526..904d8da 100644 --- a/slack-messaging/src/composition_objects/mod.rs +++ b/slack-messaging/src/composition_objects/mod.rs @@ -20,7 +20,7 @@ pub use dispatch_action_configuration::DispatchActionConfiguration; pub use option::Opt; pub use option_group::OptGroup; pub use slack_file::SlackFile; -pub use text::{Mrkdwn, Plain, Text, TextContent, TextExt}; +pub use text::{Mrkdwn, Plain, Text, TextContent, HasText}; pub use trigger::Trigger; pub use workflow::Workflow; diff --git a/slack-messaging/src/composition_objects/option.rs b/slack-messaging/src/composition_objects/option.rs index cd55620..6ef0005 100644 --- a/slack-messaging/src/composition_objects/option.rs +++ b/slack-messaging/src/composition_objects/option.rs @@ -1,5 +1,5 @@ use crate::composition_objects::{ - Plain, Text, TextExt, + Plain, Text, HasText, types::{UrlAvailable, UrlUnavailable}, }; use crate::validators::*; @@ -16,7 +16,7 @@ use std::marker::PhantomData; /// # Type Parameters /// /// * `T`: The type of text object used for the `text` and `description` fields. Defaults to -/// `Text`. Must implement the [`TextExt`] trait. +/// `Text`. Must implement the [`HasText`] trait. /// * `P`: A phantom type used to control the availability of the `url` field. Defaults to /// [`UrlUnavailable`]. Use [`UrlAvailable`] to include the `url` field. /// @@ -27,9 +27,9 @@ use std::marker::PhantomData; /// /// | Field | Type | Required | Validation | /// |-------|------|----------|------------| -/// | text | type parameter `T` bounds [TextExt] | Yes | Max length 75 characters | +/// | text | type parameter `T` bounds [HasText] | Yes | Max length 75 characters | /// | value | String | Yes | Max length 150 characters | -/// | description | type parameter `T` bounds [TextExt] | No | Max length 75 characters | +/// | description | type parameter `T` bounds [HasText] | No | Max length 75 characters | /// | url | String | No (only if type parameter `P` is [UrlAvailable]) | Max length 3000 characters | /// /// # Example @@ -93,7 +93,7 @@ use std::marker::PhantomData; #[serde(bound(serialize = "T: Serialize"))] pub struct Opt, P = UrlUnavailable> where - T: TextExt, + T: HasText, { #[serde(skip)] #[builder(phantom = "P")] @@ -114,7 +114,7 @@ where pub(crate) url: Option, } -impl OptBuilder { +impl OptBuilder { /// get url field value. pub fn get_url(&self) -> Option<&String> { self.url.inner_ref() diff --git a/slack-messaging/src/composition_objects/option_group.rs b/slack-messaging/src/composition_objects/option_group.rs index 7c9da62..1d2be88 100644 --- a/slack-messaging/src/composition_objects/option_group.rs +++ b/slack-messaging/src/composition_objects/option_group.rs @@ -1,4 +1,4 @@ -use crate::composition_objects::{Opt, Plain, Text, TextExt}; +use crate::composition_objects::{Opt, Plain, Text, HasText}; use crate::validators::*; use serde::Serialize; @@ -13,7 +13,7 @@ use slack_messaging_derive::Builder; /// # Type Parameters /// /// * `T`: The type of text object used for the `text` field of the [`Opt`] objects in the -/// `options` field. Defaults to `Text`. Must implement the [`TextExt`] trait. +/// `options` field. Defaults to `Text`. Must implement the [`HasText`] trait. /// /// # Fields and Validations /// @@ -92,7 +92,7 @@ use slack_messaging_derive::Builder; #[serde(bound(serialize = "T: Serialize"))] pub struct OptGroup> where - T: TextExt, + T: HasText, { #[builder(validate("required", "text_object::max_75"))] pub(crate) label: Option>, diff --git a/slack-messaging/src/composition_objects/text.rs b/slack-messaging/src/composition_objects/text.rs index 46810a5..5ed9bd0 100644 --- a/slack-messaging/src/composition_objects/text.rs +++ b/slack-messaging/src/composition_objects/text.rs @@ -92,12 +92,12 @@ pub struct Text { pub(crate) verbatim: Option, // for MrkdwnText } -/// Extension trait for Text objects. -pub trait TextExt { +/// A trait for types that have a `text` field, allowing access to the text content. +pub trait HasText { fn text(&self) -> Option<&str>; } -impl TextExt for Text { +impl HasText for Text { /// get text field value. fn text(&self) -> Option<&str> { self.text.as_deref() @@ -229,7 +229,7 @@ pub enum TextContent { Mrkdwn(Text), } -impl TextExt for TextContent { +impl HasText for TextContent { /// get text field value. fn text(&self) -> Option<&str> { match self { diff --git a/slack-messaging/src/validators/list.rs b/slack-messaging/src/validators/list.rs index dac703f..9a18e60 100644 --- a/slack-messaging/src/validators/list.rs +++ b/slack-messaging/src/validators/list.rs @@ -1,5 +1,5 @@ use super::*; -use crate::composition_objects::TextExt; +use crate::composition_objects::HasText; use paste::paste; @@ -60,7 +60,7 @@ pub(crate) fn not_empty(value: List) -> List { inner_validator(value, ValidationErrorKind::EmptyArray, |l| l.is_empty()) } -pub(crate) fn each_text_max_2000(value: List) -> List { +pub(crate) fn each_text_max_2000(value: List) -> List { inner_validator(value, ValidationErrorKind::MaxTextLength(2000), |l| { l.iter() .any(|t| t.text().is_some_and(|text| text.len() > 2000)) diff --git a/slack-messaging/src/validators/text_object.rs b/slack-messaging/src/validators/text_object.rs index 8e7bb10..4a80e1b 100644 --- a/slack-messaging/src/validators/text_object.rs +++ b/slack-messaging/src/validators/text_object.rs @@ -1,11 +1,11 @@ use super::*; -use crate::composition_objects::TextExt; +use crate::composition_objects::HasText; use paste::paste; fn inner_validator(mut value: Value, error: ValidationErrorKind, f: F) -> Value where - T: TextExt, + T: HasText, F: Fn(&str) -> bool, { if value.inner_ref().is_some_and(|v| v.text().is_some_and(f)) { @@ -14,7 +14,7 @@ where value } -fn max(max: usize, value: Value) -> Value { +fn max(max: usize, value: Value) -> Value { inner_validator(value, ValidationErrorKind::MaxTextLength(max), |t| { t.len() > max }) @@ -24,7 +24,7 @@ macro_rules! impl_max { ($($e:expr),*) => { paste! { $( - pub(crate) fn [](value: Value) -> Value { + pub(crate) fn [](value: Value) -> Value { max($e, value) } )* @@ -34,7 +34,7 @@ macro_rules! impl_max { impl_max!(30, 75, 100, 150, 200, 300, 2000, 3000); -pub(crate) fn min_1(value: Value) -> Value { +pub(crate) fn min_1(value: Value) -> Value { inner_validator(value, ValidationErrorKind::MinTextLength(1), |t| { t.is_empty() }) From a6c0ef0b2afe099dec562cd089f6af4f6466452f Mon Sep 17 00:00:00 2001 From: Kanji Tanaka Date: Sat, 4 Jul 2026 12:12:31 +0900 Subject: [PATCH 4/5] Fix changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f36b4..731582a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [0.7.8][] - 2026-07-04 + +- Support `Container` block. + ## [0.7.7][] - 2026-06-21 - Support `DataVisualization` block. @@ -146,6 +150,7 @@ And the `select menu element` and the `multi-select menu element` are renewed. - pre-release +[0.7.8]: https://github.com/kaicoh/slack-messaging/releases/v0.7.8 [0.7.7]: https://github.com/kaicoh/slack-messaging/releases/v0.7.7 [0.7.6]: https://github.com/kaicoh/slack-messaging/releases/v0.7.6 [0.7.5]: https://github.com/kaicoh/slack-messaging/releases/v0.7.5 From 0a46f77dad61648f707244844445a60ea5e20c5a Mon Sep 17 00:00:00 2001 From: Kanji Tanaka Date: Sat, 4 Jul 2026 12:20:59 +0900 Subject: [PATCH 5/5] Fix document --- slack-messaging/src/blocks/container.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/slack-messaging/src/blocks/container.rs b/slack-messaging/src/blocks/container.rs index 93a85fd..bfe2cb8 100644 --- a/slack-messaging/src/blocks/container.rs +++ b/slack-messaging/src/blocks/container.rs @@ -15,12 +15,12 @@ use slack_messaging_derive::Builder; /// /// | Field | Type | Required | Validation | /// |-------|------|----------|------------| -/// | title | [Text] | Yes | Maximum 150 characters | +/// | title | [`Text`] | Yes | Maximum 150 characters | /// | child_blocks | Vec<[ContainerChildBlock]> | Yes | Maximum 10 items | /// | block_id | String | No | Maximum 255 characters | /// | width | [ContainerWidth] | No | N/A | /// | subtitle | [TextContent] | No | Maximum 150 characters | -/// | icon | [elements::Image] | No | Maximum 2000 characters | +/// | icon | [elements::Image] | No | The `alt_text` field of the image must be at most 2000 characters | /// | is_collapsible | bool | No | N/A | /// | default_collapsed | bool | No | N/A | ///