diff --git a/src/blocks/header.rs b/src/blocks/header.rs new file mode 100644 index 0000000..938ee24 --- /dev/null +++ b/src/blocks/header.rs @@ -0,0 +1,203 @@ +//! # Header Block +//! +//! [slack api docs 🔗] +//! +//! A header is a plain-text block that displays in a larger, bold font. +//! +//! Use it to delineate between different groups of content in your app's surfaces. +//! +//! [slack api docs 🔗]: https://api.slack.com/reference/block-kit/blocks#header + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "validation")] +use validator::Validate; + +use crate::text; +#[cfg(feature = "validation")] +use crate::val_helpr::*; + +/// # Header Block +/// +/// [slack api docs 🔗] +/// +/// A header is a plain-text block that displays in a larger, bold font. +/// +/// Use it to delineate between different groups of content in your app's surfaces. +/// +/// [slack api docs 🔗]: https://api.slack.com/reference/block-kit/blocks#header +#[derive(Clone, Debug, Deserialize, Hash, PartialEq, Serialize)] +#[cfg_attr(feature = "validation", derive(Validate))] +pub struct Header<'a> { + #[cfg_attr(feature = "validation", validate(custom = "validate_text"))] + text: text::Text, + + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validation", + validate(custom = "super::validate_block_id"))] + block_id: Option>, +} + +#[cfg(feature = "validation")] +fn validate_text(t: &text::Text) -> ValidatorResult { + below_len("text", 150, t) +} + +impl<'a> Header<'a> { + /// Build a new Header block. + /// + /// Alias for [`build::HeaderBuilder::new()`]. + pub fn builder() -> build::HeaderBuilderInit<'a> { + build::HeaderBuilder::new() + } + + /// Validate that this Header block agrees with Slack's model requirements + /// + /// # Errors + /// - If `text` longer than 150 chars + /// - If `block_id` longer than 256 chars + /// + /// # Example + /// ``` + /// # use validator::Validate; + /// use slack_blocks::{blocks::Header, compose}; + /// + /// let long_string = |len| std::iter::repeat(' ').take(len).collect::(); + /// let assert_invalid = |block: &dyn Validate| match block.validate() { + /// | Ok(()) => panic!("validation should have failed"), + /// | Err(_) => (), + /// }; + /// + /// // block_id + /// let block = Header::builder().text("foo") + /// .block_id(long_string(256)) + /// .build(); + /// + /// assert_invalid(&block); + /// + /// // text + /// let block = Header::builder().text(long_string(151)) + /// .block_id("foo") + /// .build(); + /// + /// assert_invalid(&block); + /// ``` + #[cfg(feature = "validation")] + #[cfg_attr(docsrs, doc(cfg(feature = "validation")))] + pub fn validate(&self) -> ValidationResult { + todo!() + } +} + +pub mod build { + //! [HeaderBuilder] and related items + + use std::marker::PhantomData; + + use super::*; + use crate::build::*; + + /// Methods of [`HeaderBuilder`] + #[allow(non_camel_case_types)] + pub mod method { + /// [`super::HeaderBuilder::text()`] + #[derive(Debug, Clone, Copy)] + pub struct text; + } + + /// Initial state for [HeaderBuilder] + pub type HeaderBuilderInit<'a> = + HeaderBuilder<'a, RequiredMethodNotCalled>; + + /// Build an Header block + /// + /// Allows you to construct safely, with compile-time checks + /// on required setter methods. + /// + /// # Required Methods + /// `HeaderBuilder::build()` is only available if these methods have been called: + /// - `text` + /// + /// # Example + /// ``` + /// use slack_blocks::blocks::Header; + /// + /// let block = Header::builder().block_id("foo").text("bar").build(); + /// ``` + #[derive(Clone, Debug)] + pub struct HeaderBuilder<'a, T> { + text: Option, + block_id: Option>, + state: PhantomData, + } + + impl<'a, T> HeaderBuilder<'a, T> { + /// Construct a new HeaderBuilder + pub fn new() -> Self { + Self { text: None, + block_id: None, + state: PhantomData::<_> } + } + + /// Set `text` (**Required**) + /// + /// The text for the block, in the form of a [`plain_text` text object 🔗]. + /// + /// Maximum length for the `text` in this field is 150 characters. + /// + /// [`plain_text` text object 🔗]: https://api.slack.com/reference/messaging/composition-objects#text + pub fn text(self, + text: impl Into) + -> HeaderBuilder<'a, Set> { + HeaderBuilder { text: Some(text.into().into()), + block_id: self.block_id, + state: PhantomData::<_> } + } + + /// XML child alias for [`Self::text()`] + #[cfg(feature = "blox")] + #[cfg_attr(docsrs, doc(cfg(feature = "blox")))] + pub fn child(self, + text: impl Into) + -> HeaderBuilder<'a, Set> { + self.text(text) + } + + /// A string acting as a unique identifier for a block. + /// + /// If not specified, one will be generated. + /// + /// Maximum length for this field is 255 characters. + /// `block_id` should be unique for each message and each iteration of a message. + /// + /// If a message is updated, use a new `block_id`. + pub fn block_id(mut self, block_id: impl Into>) -> Self { + self.block_id = Some(block_id.into()); + self + } + } + + impl<'a> HeaderBuilder<'a, Set> { + /// All done building, now give me a darn actions block! + /// + /// > `no method name 'build' found for struct 'FileBuilder<...>'`? + /// Make sure all required setter methods have been called. See docs for `FileBuilder`. + /// + /// ```compile_fail + /// use slack_blocks::blocks::Header; + /// + /// let foo = Header::builder().build(); // Won't compile! + /// ``` + /// + /// ``` + /// use slack_blocks::blocks::Header; + /// + /// let block = Header::builder().text("Foo").build(); + /// ``` + pub fn build(self) -> Header<'a> { + Header { text: self.text.unwrap(), + block_id: self.block_id } + } + } +} diff --git a/src/blocks/mod.rs b/src/blocks/mod.rs index fe72b75..386631a 100644 --- a/src/blocks/mod.rs +++ b/src/blocks/mod.rs @@ -15,36 +15,34 @@ use serde::{Deserialize, Serialize}; use crate::convert; -#[doc(inline)] pub mod actions; #[doc(inline)] pub use actions::Actions; -#[doc(inline)] pub mod context; #[doc(inline)] pub use context::Context; -#[doc(inline)] pub mod file; #[doc(inline)] pub use file::File; -#[doc(inline)] pub mod image; #[doc(inline)] pub use image::Image; -#[doc(inline)] pub mod input; #[doc(inline)] pub use input::Input; -#[doc(inline)] pub mod section; #[doc(inline)] pub use section::Section; +pub mod header; +#[doc(inline)] +pub use header::Header; + /// # Layout Blocks /// /// Blocks are a series of components that can be combined @@ -55,7 +53,7 @@ pub use section::Section; /// You can include up to 50 blocks in each message, and 100 blocks in modals or home tabs. /// /// [building block layouts 🔗]: https://api.slack.com/block-kit/building -#[derive(Serialize, Deserialize, Debug)] +#[derive(Hash, PartialEq, Serialize, Deserialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Block<'a> { /// # Section Block @@ -85,6 +83,9 @@ pub enum Block<'a> { /// # Input Block Input(Input<'a>), + /// # Input Block + Header(Header<'a>), + /// # File Block File(File<'a>), } @@ -92,6 +93,7 @@ pub enum Block<'a> { impl fmt::Display for Block<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let kind = match self { + | Block::Header { .. } => "Header", | Block::Section { .. } => "Section", | Block::Divider => "Divider", | Block::Image { .. } => "Image", @@ -128,6 +130,7 @@ impl<'a> Block<'a> { | Actions(contents) => contents.validate(), | Context(contents) => contents.validate(), | Input(contents) => contents.validate(), + | Header(contents) => contents.validate(), | File(contents) => contents.validate(), | Divider => Ok(()), } @@ -140,6 +143,7 @@ convert!(impl<'a> From> for Block<'a> => |a| Block::Section(a)); convert!(impl<'a> From> for Block<'a> => |a| Block::Image(a)); convert!(impl<'a> From> for Block<'a> => |a| Block::Context(a)); convert!(impl<'a> From> for Block<'a> => |a| Block::File(a)); +convert!(impl<'a> From> for Block<'a> => |a| Block::Header(a)); /// Error yielded when `TryFrom` is called on an unsupported block element. #[derive(Clone, Debug, Deserialize, Hash, PartialEq, Serialize)] diff --git a/src/blox.rs b/src/blox.rs index 93b7e94..103860f 100644 --- a/src/blox.rs +++ b/src/blox.rs @@ -1,4 +1,86 @@ //! # XML macro builder support +//! +//! # Blocks +//! +//! [`blocks::Actions`] - `<`[`actions_block`]`>` +//! +//! [`blocks::Header`] - `<`[`header_block`]`>` or `<`[`h1`]`>` +//! +//! [`blocks::Block::Divider`] - `<`[`divider_block`]`>` or `<`[`hr`]`>` +//! +//! [`blocks::Section`] - `<`[`section_block`]`>` +//! +//! [`blocks::Input`] - `<`[`input_block`]`>` +//! +//! [`blocks::Context`] - `<`[`context_block`]`>` +//! +//! [`blocks::File`] - `<`[`file_block`]`>` +//! +//! [`blocks::Image`] - `<`[`img_block`]`>` +//! +//! # Block Elements +//! +//! [`elems::TextInput`] - `<`[`text_input`]`>` +//! +//! [`elems::Image`] - `<`[`img`]`>` +//! +//! [`elems::Button`] - `<`[`button`]`>` +//! +//! [`elems::Checkboxes`] - `<`[`checkboxes`]`>` +//! +//! [`elems::DatePicker`] - `<`[`date_picker`]`>` +//! +//! [`elems::Overflow`] - `<`[`overflow`]`>` +//! +//! [`elems::Radio`] - `<`[`radio_buttons`]`>` +//! +//! [`elems::select`] - `<`[`select`]`>` +//! +//! # Composition Objects +//! +//! [`compose::text`] - `<`[`text()`]`>` +//! +//! [`compose::opt`] - `<`[`option`]`>` +//! +//! [`compose::opt_group`] - `<`[`option_group`]`>` +//! +//! [`compose::Confirm`] - `<`[`confirm`]`>` +//! +//! # Example +//! Using an example from Slack's Documentation: +//! ```json +//! { +//! "type": "section", +//! "text": { +//! "text": "*Sally* has requested you set the deadline for the Nano launch project", +//! "type": "mrkdwn" +//! }, +//! "accessory": { +//! "type": "datepicker", +//! "action_id": "datepicker123", +//! "initial_date": "1990-04-28", +//! "placeholder": { +//! "type": "plain_text", +//! "text": "Select a date" +//! } +//! } +//! } +//! ``` +//! ```rust +//! use slack_blocks::blox::*; +//! +//! let pick_date = blox! { +//! +//! }; +//! +//! let section = blox! { +//! +//! "*Sally* has requested you set the deadline for the Nano launch project" +//! +//! }; +//! ``` pub use elems::{button::Style::{Danger as btn_danger, Primary as btn_primary}, @@ -30,10 +112,24 @@ pub use blox_elems::*; mod blox_blocks { use super::*; - /// # Build an actions block + /// Dummy builder so [blocks::Block::Divider] can be built with XML macro + #[derive(Debug, Copy, Clone)] + pub struct DividerBuilder; + impl DividerBuilder { + /// Constructs [blocks::Block::Divider] + pub fn build(self) -> blocks::Block<'static> { + blocks::Block::Divider + } + } + + /// # [`blocks::Actions`] - `` + /// + /// Build a [`blocks::Actions`] /// - /// ## Children - /// Accepts at least one, and up to 5 supported block elements as children. + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`element`](blocks::actions::build::ActionsBuilder::element())|`impl Into<`[`blocks::actions::SupportedElement`]`>`|❌|✅| + /// |[`block_id`](blocks::actions::build::ActionsBuilder::block_id())|[`String`] or [`&str`]|✅|❌| /// /// ## Example /// ``` @@ -64,20 +160,101 @@ mod blox_blocks { blocks::Actions::builder() } - /// # Build a section block + /// # [`blocks::Header`] - `` or `

` + /// + /// Build a [`blocks::Header`] + /// + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`text`](blocks::header::build::HeaderBuilder::text())|[`text::Plain`], [`String`], [`&str`]|❌|✅| + /// |[`block_id`](blocks::header::build::HeaderBuilder::block_id())|[`String`] or [`&str`]|✅|❌| + /// + /// ## Example + /// ``` + /// use slack_blocks::{blocks::Header, blox::*}; + /// + /// let xml = blox! { + ///

"Foo"

+ /// }; + /// + /// let equivalent = Header::builder().text("Foo").build(); + /// + /// assert_eq!(xml, equivalent); + /// ``` + pub fn header_block() -> blocks::header::build::HeaderBuilderInit<'static> { + blocks::Header::builder() + } + + /// Alias for [`header_block`] + pub fn h1() -> blocks::header::build::HeaderBuilderInit<'static> { + blocks::Header::builder() + } + + /// # [`blocks::Block::Divider`] - `` or `
` + /// + /// Build a [`blocks::Block::Divider`] + /// + /// # Attributes + /// None + /// + /// ## Example + /// ``` + /// use slack_blocks::{blocks::Block, blox::*}; + /// + /// let xml = blox! { + ///
+ /// }; + /// + /// let equivalent = Block::Divider; + /// + /// assert_eq!(xml, equivalent); + /// ``` + pub fn divider_block() -> DividerBuilder { + DividerBuilder + } + + /// Alias for [`divider_block`] + pub fn hr() -> DividerBuilder { + divider_block() + } + + /// # [`blocks::Section`] - `` /// - /// ## Children - /// Accepts at least one, and up to 10 text objects as children. + /// Build a [`blocks::Section`] + /// + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`text`] | [`text::Plain`], [`text::Mrkdwn`], or [`text::Text`]|❌*|❌| + /// |[`field`] | [`text::Plain`], [`text::Mrkdwn`], or [`text::Text`]|❌*|✅| + /// |[`fields`] | [`IntoIterator`] over [`text::Text`] |❌*|❌| + /// |[`accessory`]| [`elems::BlockElement`] |✅ |❌| + /// |[`block_id`] | [`String`] or [`&str`] |✅ |❌| + /// + /// * `text`, `field(s)`, or both are required. + /// + /// [`text`]: blocks::section::build::SectionBuilder::text() + /// [`field`]: blocks::section::build::SectionBuilder::field() + /// [`fields`]: blocks::section::build::SectionBuilder::fields() + /// [`accessory`]: blocks::section::build::SectionBuilder::accessory() + /// [`block_id`]: blocks::section::build::SectionBuilder::block_id() /// /// ## Example /// ``` /// use slack_blocks::{blocks::Section, blox::*, text}; /// + /// let section_text = blox! { "Foo" }; + /// /// let xml = blox! { - /// "Foo"} /> + /// + /// "Bar" + /// /// }; /// - /// let equivalent = Section::builder().text(text::Plain::from("Foo")).build(); + /// let equivalent = Section::builder().text(text::Plain::from("Foo")) + /// .field(text::Mrkdwn::from("Bar")) + /// .build(); /// /// assert_eq!(xml, equivalent); /// ``` @@ -88,12 +265,19 @@ mod blox_blocks { blocks::Section::builder() } - /// # Build an input block + /// # [`blocks::Input`] - `` /// - /// ## Children - /// Input requires a single child of a supported block element. + /// Build a [`blocks::Input`] /// - /// For the list of supported elements, see `slack_blocks::blocks::input::SupportedElement`. + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`label`](blocks::input::build::InputBuilder::label())|[`text::Plain`], [`text::Mrkdwn`], or [`text::Text`] ([``](super::text()))|❌|❌| + /// |[`element`](blocks::input::build::InputBuilder::element())|`impl Into<`[`blocks::input::SupportedElement`]`>`|❌|✅| + /// |[`block_id`](blocks::input::build::InputBuilder::block_id())|[`String`] or [`&str`]|✅|❌| + /// |[`hint`](blocks::input::build::InputBuilder::hint())|[`text::Plain`] ([``](super::text())), [`String`], or [`&str`]|✅|❌| + /// |[`dispatch_actions`](blocks::input::build::InputBuilder::dispatch_actions())|[`bool`]|✅|❌| + /// |[`optional`](blocks::input::build::InputBuilder::optional())|[`bool`]|✅|❌| /// /// ## Example /// ``` @@ -116,10 +300,15 @@ mod blox_blocks { blocks::Input::builder() } - /// # Build a context block + /// # [`blocks::Context`] - `` + /// + /// Build a [`blocks::Context`] /// - /// ## Children - /// Allows at least one, up to 10 text or img elements. + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`element`](blocks::context::build::ContextBuilder::element())|[`text::Text`] ([``](super::text())) or [`elems::Image`] ([``](super::img()))|❌|✅| + /// |[`block_id`](blocks::context::build::ContextBuilder::block_id())|[`String`] or [`&str`]|✅|❌| /// /// ## Example /// ``` @@ -148,10 +337,15 @@ mod blox_blocks { blocks::Context::builder() } - /// # Build a file block + /// # [`blocks::File`] - `` /// - /// ## Children - /// None + /// Build a [`blocks::File`] + /// + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`external_id`](blocks::file::build::FileBuilder::external_id())|[`String`] or [`&str`]|❌|✅| + /// |[`block_id`](blocks::file::build::FileBuilder::block_id())|[`String`] or [`&str`]|✅|❌| /// /// ## Example /// ``` @@ -169,10 +363,16 @@ mod blox_blocks { blocks::File::builder() } - /// # Build an image block + /// # [`blocks::Image`] - `` + /// + /// Build a [`blocks::Image`] /// - /// ## Children - /// Allows at least one, up to 10 text or img elements. + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`src`](blocks::image::build::ImageBuilder::src())|[`String`] or [`&str`]|❌|❌| + /// |[`alt`](blocks::image::build::ImageBuilder::alt())|[`String`] or [`&str`]|❌|❌| + /// |[`block_id`](blocks::file::build::FileBuilder::block_id())|[`String`] or [`&str`]|✅|❌| /// /// ## Example /// ``` @@ -196,10 +396,30 @@ mod blox_blocks { mod blox_elems { use super::*; - /// # Build an text input element + /// # [`elems::TextInput`] - `` /// - /// ## Children - /// None. + /// Build a [`elems::TextInput`] + /// + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`action_id`] | [`String`] or [`&str`] |❌|❌| + /// |[`action_trigger`] | [`elems::text_input::ActionTrigger`] |✅|❌| + /// |[`placeholder`] | [`text::Plain`] ([``](super::text())), [`String`] or [`&str`] |✅|❌| + /// |[`initial_value`] | [`String`] or [`&str`] |✅|❌| + /// |[`length`] | impl [`std::ops::RangeBounds`] over [`u32`] |✅|❌| + /// |[`min_length`] | [`u32`] |✅|❌| + /// |[`max_length`] | [`u32`] |✅|❌| + /// |[`multiline`] | [`bool`] |✅|❌| + /// + /// [`action_id`]: elems::text_input::build::TextInputBuilder::action_id() + /// [`action_trigger`]: elems::text_input::build::TextInputBuilder::action_trigger() + /// [`placeholder`]: elems::text_input::build::TextInputBuilder::placeholder() + /// [`initial_value`]: elems::text_input::build::TextInputBuilder::initial_value() + /// [`length`]: elems::text_input::build::TextInputBuilder::length() + /// [`min_length`]: elems::text_input::build::TextInputBuilder::min_length() + /// [`max_length`]: elems::text_input::build::TextInputBuilder::max_length() + /// [`multiline`]: elems::text_input::build::TextInputBuilder::multiline() /// /// ## Example /// ``` @@ -228,10 +448,15 @@ mod blox_elems { elems::TextInput::builder() } - /// # Build an image element + /// # [`elems::Image`] - `` /// - /// ## Children - /// None. + /// Build a [`elems::Image`] + /// + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`src`](elems::image::build::ImageBuilder::src()) | [`String`] or [`&str`] |❌|❌| + /// |[`alt`](elems::image::build::ImageBuilder::alt()) | [`String`] or [`&str`] |❌|❌| /// /// ## Example /// ``` @@ -251,21 +476,39 @@ mod blox_elems { elems::Image::builder() } - /// # Build a button + /// # [`elems::Button`] - ` + /// /// }; /// - /// let equiv = Button::builder().action_id("click_me") - /// .text("Click me!") + /// let equiv = Button::builder().action_id("dangerous") + /// .text("DANGER!") + /// .style(Style::Danger) /// .build(); /// /// assert_eq!(xml, equiv) @@ -274,10 +517,26 @@ mod blox_elems { elems::Button::builder() } - /// # Build a checkbox group + /// # [`elems::Checkboxes`] - `` + /// + /// Build a [`elems::Checkboxes`] /// - /// ## Children - /// Options to populate the checkbox group with. Min 1, max 10 + /// # Attributes + /// |Attribute|Type|Optional|Available as child| + /// |-|-|-|-| + /// |[`action_id`] | [`String`] or [`&str`] |❌|❌| + /// |[`option`] | [`compose::Opt`] ([` /// }; /// - /// let radio = Radio::builder().action_id("cheese_picker") - /// .option(Opt::builder().value("feta") - /// .text_plain("Feta") - /// .build()) - /// .option(Opt::builder().value("gouda") - /// .text_plain("Gouda") - /// .build()) - /// .option(Opt::builder().value("cheddar") - /// .text_plain("Cheddar") - /// .build()) - /// .build(); + /// let equiv = { + /// let feta = Opt::builder().value("feta").text_plain("Feta").build(); /// - /// let equiv = Input::builder().label("Pick your favorite cheese!") - /// .element(radio) - /// .build(); + /// let gouda = Opt::builder().value("gouda").text_plain("Gouda").build(); + /// + /// let cheddar = Opt::builder().value("cheddar") + /// .text_plain("Cheddar") + /// .build(); + /// + /// let radio = Radio::builder().action_id("cheese_picker") + /// .option(feta) + /// .option(gouda) + /// .option(cheddar) + /// .build(); + /// + /// Input::builder().label("Pick your favorite cheese!") + /// .element(radio) + /// .build() + /// }; /// /// assert_eq!(xml, equiv) /// ``` @@ -417,23 +722,121 @@ mod blox_elems { elems::Radio::builder() } - /// # Build a select menu - /// - /// # Attributes - /// - `kind` (Optional): `single` or `multi` from `slack_blocks::blox`. Default is `single`. - /// - `choose_from` (Required): `users`, `public_channels`, `static_`, `external`, `conversations` from `slack_blocks::blox` - /// - /// # Children - /// For `static_`, 1-100 `