diff --git a/slack-messaging-derive/src/types.rs b/slack-messaging-derive/src/types.rs index d533ce1..0043df6 100644 --- a/slack-messaging-derive/src/types.rs +++ b/slack-messaging-derive/src/types.rs @@ -114,6 +114,21 @@ impl Field { } else { quote! {} }; + let (setter_generic, setter_arg, setter_logic, setter_bounds) = if let InnerType::Vec(inner_ty) = InnerType::new(self.inner_ty()) { + ( + quote! { }, + quote! { Iter }, + quote! { val.into_iter().map(|v| v.into()).collect() }, + quote! { where Iter: IntoIterator, Inner: Into<#inner_ty> } + ) + } else { + ( + quote! {}, + quote! { impl Into<#ty> }, + quote! { val.into() }, + quote! {}, + ) + }; let push_item = match (self.push_item.as_ref(), InnerType::new(self.inner_ty())) { (Some(expr), InnerType::Vec(inner_ty)) => { @@ -144,15 +159,15 @@ impl Field { } #[doc = #doc_setter] - #setter_visibility fn #setter(self, value: ::std::option::Option>) -> Self { + #setter_visibility fn #setter #setter_generic (self, value: ::std::option::Option<#setter_arg>) -> Self #setter_bounds { Self { - #ident: Self::#constructor_name(value.map(|v| v.into())), + #ident: Self::#constructor_name(value.map(|val| #setter_logic)), #after_set } } #[doc = #doc_setter] - #setter_visibility fn #ident(self, value: impl Into<#ty>) -> Self { + #setter_visibility fn #ident #setter_generic(self, value: #setter_arg) -> Self #setter_bounds { self.#setter(Some(value)) } diff --git a/slack-messaging/src/blocks/actions.rs b/slack-messaging/src/blocks/actions.rs index 94eda7c..4090e69 100644 --- a/slack-messaging/src/blocks/actions.rs +++ b/slack-messaging/src/blocks/actions.rs @@ -413,8 +413,8 @@ mod tests { .set_block_id(Some("actions_0")) .set_elements(Some(vec![ datepicker().into(), - btn("button_0", "value_0").into(), - ])) + btn("button_0", "value_0").into() + ] as Vec)) .build() .unwrap(); @@ -422,7 +422,10 @@ mod tests { let val = Actions::builder() .block_id("actions_0") - .elements(vec![datepicker().into(), btn("button_0", "value_0").into()]) + .elements(vec![ + datepicker().into(), + btn("button_0", "value_0").into() + ] as Vec) .build() .unwrap(); diff --git a/slack-messaging/src/blocks/builders.rs b/slack-messaging/src/blocks/builders.rs index 2f23a66..39e2153 100644 --- a/slack-messaging/src/blocks/builders.rs +++ b/slack-messaging/src/blocks/builders.rs @@ -5,6 +5,7 @@ pub use super::carousel::CarouselBuilder; pub use super::context::ContextBuilder; pub use super::context_actions::ContextActionsBuilder; pub use super::data_table::DataTableBuilder; +pub use super::data_visualization::DataVisualizationBuilder; pub use super::divider::DividerBuilder; pub use super::file::FileBuilder; pub use super::header::HeaderBuilder; diff --git a/slack-messaging/src/blocks/carousel.rs b/slack-messaging/src/blocks/carousel.rs index 6d5f7ff..80744b4 100644 --- a/slack-messaging/src/blocks/carousel.rs +++ b/slack-messaging/src/blocks/carousel.rs @@ -314,7 +314,10 @@ mod tests { #[test] fn it_requires_elements_to_have_at_least_1_item() { - let err = Carousel::builder().elements(vec![]).build().unwrap_err(); + let err = Carousel::builder() + .elements(vec![] as Vec) + .build() + .unwrap_err(); assert_eq!(err.object(), "Carousel"); let errors = err.field("elements"); diff --git a/slack-messaging/src/blocks/context.rs b/slack-messaging/src/blocks/context.rs index 3598e45..1d90023 100644 --- a/slack-messaging/src/blocks/context.rs +++ b/slack-messaging/src/blocks/context.rs @@ -117,7 +117,7 @@ mod tests { let val = Context::builder() .set_block_id(Some("context_0")) - .set_elements(Some(vec![mrkdwn_text("foo").into()])) + .set_elements(Some(vec![mrkdwn_text("foo")])) .build() .unwrap(); @@ -125,7 +125,7 @@ mod tests { let val = Context::builder() .block_id("context_0") - .elements(vec![mrkdwn_text("foo").into()]) + .elements(vec![mrkdwn_text("foo")]) .build() .unwrap(); diff --git a/slack-messaging/src/blocks/context_actions.rs b/slack-messaging/src/blocks/context_actions.rs index 542a8bc..139ca83 100644 --- a/slack-messaging/src/blocks/context_actions.rs +++ b/slack-messaging/src/blocks/context_actions.rs @@ -183,7 +183,7 @@ mod tests { let val = ContextActions::builder() .set_block_id(Some("context_actions_0")) - .set_elements(Some(vec![fb_buttons().into()])) + .set_elements(Some(vec![fb_buttons()])) .build() .unwrap(); @@ -191,7 +191,7 @@ mod tests { let val = ContextActions::builder() .block_id("context_actions_0") - .elements(vec![fb_buttons().into()]) + .elements(vec![fb_buttons()]) .build() .unwrap(); diff --git a/slack-messaging/src/blocks/data_visualization/charts/area_chart.rs b/slack-messaging/src/blocks/data_visualization/charts/area_chart.rs new file mode 100644 index 0000000..caae937 --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/area_chart.rs @@ -0,0 +1,309 @@ +use super::{AxisConfig, DataSeries, ValidateXYChart}; + +use crate::errors::ValidationErrorKind; +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Area chart +/// block](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#area) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#area). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | series | Vec<[DataSeries]> | Yes | Min 1, Max 6 items, Unique names, Labels must match axis categories | +/// | axis_config | [AxisConfig] | Yes | N/A | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::{data_points, DataSeries, AreaChart, +/// AxisConfig}; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let area_chart = AreaChart::builder() +/// .series(vec![ +/// DataSeries::builder() +/// .name("Pied Piper Free Tier") +/// .data(data_points(vec![ +/// ("Mon", 12000), +/// ("Tue", 13500), +/// ("Wed", 15200), +/// ("Thu", 14800), +/// ("Fri", 16400), +/// ])?) +/// .build()?, +/// DataSeries::builder() +/// .name("Pied Piper Paid Tier") +/// .data(data_points(vec![ +/// ("Mon", 4500), +/// ("Tue", 4800), +/// ("Wed", 5100), +/// ("Thu", 5600), +/// ("Fri", 6200), +/// ])?) +/// .build()?, +/// ]) +/// .axis_config( +/// AxisConfig::builder() +/// .categories(vec![ +/// "Mon", +/// "Tue", +/// "Wed", +/// "Thu", +/// "Fri", +/// ]) +/// .x_label("Day") +/// .y_label("Users") +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "area", +/// "series": [ +/// { +/// "name": "Pied Piper Free Tier", +/// "data": [ +/// { "label": "Mon", "value": 12000 }, +/// { "label": "Tue", "value": 13500 }, +/// { "label": "Wed", "value": 15200 }, +/// { "label": "Thu", "value": 14800 }, +/// { "label": "Fri", "value": 16400 } +/// ] +/// }, +/// { +/// "name": "Pied Piper Paid Tier", +/// "data": [ +/// { "label": "Mon", "value": 4500 }, +/// { "label": "Tue", "value": 4800 }, +/// { "label": "Wed", "value": 5100 }, +/// { "label": "Thu", "value": 5600 }, +/// { "label": "Fri", "value": 6200 } +/// ] +/// } +/// ], +/// "axis_config": { +/// "categories": [ +/// "Mon", +/// "Tue", +/// "Wed", +/// "Thu", +/// "Fri" +/// ], +/// "x_label": "Day", +/// "y_label": "Users" +/// } +/// }); +/// +/// let json = serde_json::to_value(area_chart).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[builder(validate = "validate")] +#[serde(tag = "type", rename = "area")] +pub struct AreaChart { + #[builder( + push_item = "push_series", + validate("required", "list::not_empty", "list::max_item_6", "list::unique_series_names") + )] + pub(crate) series: Option>, + + #[builder(validate("required"))] + pub(crate) axis_config: Option, +} + +impl ValidateXYChart for AreaChart { + fn series(&self) -> Option<&[DataSeries]> { + self.series.as_deref() + } + + fn axis_config(&self) -> Option<&AxisConfig> { + self.axis_config.as_ref() + } +} + +fn validate(val: &AreaChart) -> Vec { + val.validate() +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::data_points; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = AreaChart { + series: Some(vec![data_series("Pie")]), + axis_config: Some(axis_config()), + }; + + let val = AreaChart::builder() + .set_series(Some(vec![data_series("Pie")])) + .set_axis_config(Some(axis_config())) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = AreaChart::builder() + .series(vec![data_series("Pie")]) + .axis_config(axis_config()) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = AreaChart { + series: Some(vec![data_series("Pie")]), + axis_config: Some(axis_config()), + }; + + let val = AreaChart::builder() + .push_series(data_series("Pie")) + .axis_config(axis_config()) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_series_field() { + let err = AreaChart::builder() + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AreaChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_series_field_to_have_at_least_one_item() { + let err = AreaChart::builder() + .series(vec![] as Vec) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AreaChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::EmptyArray)); + } + + #[test] + fn it_requires_series_field_to_have_no_more_than_six_items() { + let err = AreaChart::builder() + .series(vec![ + data_series("Series 1"), + data_series("Series 2"), + data_series("Series 3"), + data_series("Series 4"), + data_series("Series 5"), + data_series("Series 6"), + data_series("Series 7"), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AreaChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::MaxArraySize(6))); + } + + #[test] + fn it_requires_axis_config_field() { + let err = AreaChart::builder() + .series(vec![data_series("Pie")]) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AreaChart"); + + let errors = err.field("axis_config"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_series_names_to_be_unique() { + let err = AreaChart::builder() + .series(vec![ + data_series("Sales"), + data_series("Revenue"), + data_series("Sales"), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AreaChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::UniqueSeriesName)); + } + + #[test] + fn it_requires_series_labels_to_match_axis_categories() { + let err = AreaChart::builder() + .series(vec![ + data_series("Pie"), + DataSeries::builder() + .name("Cake") + .data(data_points(vec![ + ("Chocolate", 90), + ("Vanilla", 80), + ]).unwrap()) + .build() + .unwrap(), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AreaChart"); + + let errors = err.across_fields(); + assert!(errors.includes(ValidationErrorKind::DataPointLabelMatching)); + } + + fn data_series(name: &str) -> DataSeries { + DataSeries::builder() + .name(name) + .data(data_points(vec![ + ("Strawberry Rhubarb", 85), + ("Pumpkin", 70), + ]).unwrap()) + .build() + .unwrap() + } + + fn axis_config() -> AxisConfig { + AxisConfig::builder() + .categories(vec![ + "Strawberry Rhubarb", + "Pumpkin", + ]) + .x_label("Pies") + .y_label("Percentage of Tastiness") + .build() + .unwrap() + } +} diff --git a/slack-messaging/src/blocks/data_visualization/charts/axis_config.rs b/slack-messaging/src/blocks/data_visualization/charts/axis_config.rs new file mode 100644 index 0000000..3c8320d --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/axis_config.rs @@ -0,0 +1,172 @@ +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Axis Config](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#axis-config) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#axis-config). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | categories | `Vec` | Yes | Each category label has a maximum of 20 characters | +/// | x_label | String | No | Max length: 50 characters | +/// | y_label | String | No | Max length: 50 characters | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::AxisConfig; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let config = AxisConfig::builder() +/// .categories(vec!["Mon", "Tue", "Wed", "Thu", "Fri"]) +/// .x_label("Days of the Week") +/// .y_label("Sales") +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "categories": ["Mon", "Tue", "Wed", "Thu", "Fri"], +/// "x_label": "Days of the Week", +/// "y_label": "Sales" +/// }); +/// +/// let json = serde_json::to_value(config).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +pub struct AxisConfig { + #[builder(push_item = "category", validate("required", "list::each_max_20_chars"))] + pub(crate) categories: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text::max_50"))] + pub(crate) x_label: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text::max_50"))] + pub(crate) y_label: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = AxisConfig { + categories: Some(vec![ + "Mon".to_string(), + "Tue".to_string(), + "Wed".to_string(), + "Thu".to_string(), + "Fri".to_string(), + ]), + x_label: Some("Days of the Week".to_string()), + y_label: Some("Sales".to_string()), + }; + + let val = AxisConfig::builder() + .set_categories(Some(vec!["Mon", "Tue", "Wed", "Thu", "Fri"])) + .set_x_label(Some("Days of the Week")) + .set_y_label(Some("Sales")) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = AxisConfig::builder() + .categories(vec!["Mon", "Tue", "Wed", "Thu", "Fri"]) + .x_label("Days of the Week") + .y_label("Sales") + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = AxisConfig { + categories: Some(vec![ + "Mon".to_string(), + "Tue".to_string(), + "Wed".to_string(), + "Thu".to_string(), + "Fri".to_string(), + ]), + x_label: None, + y_label: None, + }; + + let val = AxisConfig::builder() + .category("Mon") + .category("Tue") + .category("Wed") + .category("Thu") + .category("Fri") + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_categories_field() { + let err = AxisConfig::builder().build().unwrap_err(); + assert_eq!(err.object(), "AxisConfig"); + + let errors = err.field("categories"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_categories_each_max_20_chars() { + let err = AxisConfig::builder() + .categories(vec!["a".repeat(21), "foo".into()]) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AxisConfig"); + + let errors = err.field("categories"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(20))); + } + + #[test] + fn it_requires_x_label_max_50_chars() { + let err = AxisConfig::builder() + .categories(vec!["foo"]) + .x_label("a".repeat(51)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AxisConfig"); + + let errors = err.field("x_label"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(50))); + } + + #[test] + fn it_requires_y_label_max_50_chars() { + let err = AxisConfig::builder() + .categories(vec!["foo"]) + .y_label("a".repeat(51)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "AxisConfig"); + + let errors = err.field("y_label"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(50))); + } +} diff --git a/slack-messaging/src/blocks/data_visualization/charts/bar_chart.rs b/slack-messaging/src/blocks/data_visualization/charts/bar_chart.rs new file mode 100644 index 0000000..bd4c422 --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/bar_chart.rs @@ -0,0 +1,288 @@ +use super::{AxisConfig, DataSeries, ValidateXYChart}; + +use crate::errors::ValidationErrorKind; +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Bar chart block](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#bar) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#bar). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | series | Vec<[DataSeries]> | Yes | Min 1, Max 6 items, Unique names, Labels must match axis categories | +/// | axis_config | [AxisConfig] | Yes | N/A | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::{data_points, DataSeries, BarChart, +/// AxisConfig}; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let bar_chart = BarChart::builder() +/// .series(vec![ +/// DataSeries::builder() +/// .name("Pie") +/// .data(data_points(vec![ +/// ("Strawberry Rhubarb", 85), +/// ("Pumpkin", 70), +/// ("Lemon Meringue", 72), +/// ("Blueberry", 90), +/// ("Key Lime", 56), +/// ])?) +/// .build()? +/// ]) +/// .axis_config( +/// AxisConfig::builder() +/// .categories(vec![ +/// "Strawberry Rhubarb", +/// "Pumpkin", +/// "Lemon Meringue", +/// "Blueberry", +/// "Key Lime", +/// ]) +/// .x_label("Pies") +/// .y_label("Percentage of Tastiness") +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "bar", +/// "series": [ +/// { +/// "name": "Pie", +/// "data": [ +/// { "label": "Strawberry Rhubarb", "value": 85 }, +/// { "label": "Pumpkin", "value": 70 }, +/// { "label": "Lemon Meringue", "value": 72 }, +/// { "label": "Blueberry", "value": 90 }, +/// { "label": "Key Lime", "value": 56 } +/// ] +/// } +/// ], +/// "axis_config": { +/// "categories": [ +/// "Strawberry Rhubarb", +/// "Pumpkin", +/// "Lemon Meringue", +/// "Blueberry", +/// "Key Lime" +/// ], +/// "x_label": "Pies", +/// "y_label": "Percentage of Tastiness" +/// } +/// }); +/// +/// let json = serde_json::to_value(bar_chart).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[builder(validate = "validate")] +#[serde(tag = "type", rename = "bar")] +pub struct BarChart { + #[builder( + push_item = "push_series", + validate("required", "list::not_empty", "list::max_item_6", "list::unique_series_names") + )] + pub(crate) series: Option>, + + #[builder(validate("required"))] + pub(crate) axis_config: Option, +} + +impl ValidateXYChart for BarChart { + fn series(&self) -> Option<&[DataSeries]> { + self.series.as_deref() + } + + fn axis_config(&self) -> Option<&AxisConfig> { + self.axis_config.as_ref() + } +} + +fn validate(val: &BarChart) -> Vec { + val.validate() +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::data_points; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = BarChart { + series: Some(vec![data_series("Pie")]), + axis_config: Some(axis_config()), + }; + + let val = BarChart::builder() + .set_series(Some(vec![data_series("Pie")])) + .set_axis_config(Some(axis_config())) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = BarChart::builder() + .series(vec![data_series("Pie")]) + .axis_config(axis_config()) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = BarChart { + series: Some(vec![data_series("Pie")]), + axis_config: Some(axis_config()), + }; + + let val = BarChart::builder() + .push_series(data_series("Pie")) + .axis_config(axis_config()) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_series_field() { + let err = BarChart::builder() + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "BarChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_series_field_to_have_at_least_one_item() { + let err = BarChart::builder() + .series(vec![] as Vec) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "BarChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::EmptyArray)); + } + + #[test] + fn it_requires_series_field_to_have_no_more_than_six_items() { + let err = BarChart::builder() + .series(vec![ + data_series("Series 1"), + data_series("Series 2"), + data_series("Series 3"), + data_series("Series 4"), + data_series("Series 5"), + data_series("Series 6"), + data_series("Series 7"), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "BarChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::MaxArraySize(6))); + } + + #[test] + fn it_requires_axis_config_field() { + let err = BarChart::builder() + .series(vec![data_series("Pie")]) + .build() + .unwrap_err(); + assert_eq!(err.object(), "BarChart"); + + let errors = err.field("axis_config"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_series_names_to_be_unique() { + let err = BarChart::builder() + .series(vec![ + data_series("Sales"), + data_series("Revenue"), + data_series("Sales"), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "BarChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::UniqueSeriesName)); + } + + #[test] + fn it_requires_series_labels_to_match_axis_categories() { + let err = BarChart::builder() + .series(vec![ + data_series("Pie"), + DataSeries::builder() + .name("Cake") + .data(data_points(vec![ + ("Chocolate", 90), + ("Vanilla", 80), + ]).unwrap()) + .build() + .unwrap(), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "BarChart"); + + let errors = err.across_fields(); + assert!(errors.includes(ValidationErrorKind::DataPointLabelMatching)); + } + + fn data_series(name: &str) -> DataSeries { + DataSeries::builder() + .name(name) + .data(data_points(vec![ + ("Strawberry Rhubarb", 85), + ("Pumpkin", 70), + ]).unwrap()) + .build() + .unwrap() + } + + fn axis_config() -> AxisConfig { + AxisConfig::builder() + .categories(vec![ + "Strawberry Rhubarb", + "Pumpkin", + ]) + .x_label("Pies") + .y_label("Percentage of Tastiness") + .build() + .unwrap() + } +} diff --git a/slack-messaging/src/blocks/data_visualization/charts/builders.rs b/slack-messaging/src/blocks/data_visualization/charts/builders.rs new file mode 100644 index 0000000..b25fef2 --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/builders.rs @@ -0,0 +1,8 @@ +pub use super::area_chart::AreaChartBuilder; +pub use super::axis_config::AxisConfigBuilder; +pub use super::bar_chart::BarChartBuilder; +pub use super::data_point::DataPointBuilder; +pub use super::data_series::DataSeriesBuilder; +pub use super::line_chart::LineChartBuilder; +pub use super::pie_chart::PieChartBuilder; +pub use super::segment::SegmentBuilder; diff --git a/slack-messaging/src/blocks/data_visualization/charts/data_point.rs b/slack-messaging/src/blocks/data_visualization/charts/data_point.rs new file mode 100644 index 0000000..8e373e5 --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/data_point.rs @@ -0,0 +1,194 @@ +use crate::errors::ValidationErrors; +use crate::validators::*; + +use serde::Serialize; +use serde_json::Number; +use slack_messaging_derive::Builder; + +/// [Data Point](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#data-point) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#data-point). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | label | String | Yes | Max length 20 characters | +/// | value | [Number] | Yes | N/A | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::DataPoint; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let point = DataPoint::builder() +/// .label("Sales") +/// .value(150) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "label": "Sales", +/// "value": 150 +/// }); +/// +/// let json = serde_json::to_value(point).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +pub struct DataPoint { + #[builder(validate("required", "text::max_20"))] + pub(crate) label: Option, + + #[builder(validate("required"))] + pub(crate) value: Option, +} + +impl TryFrom<(S, N)> for DataPoint +where + S: Into, + N: Into, +{ + type Error = ValidationErrors; + + fn try_from((label, value): (S, N)) -> Result { + DataPoint::builder() + .label(label) + .value(value) + .build() + } +} + +/// Helper function to create multiple data points from an iterator of tuples. +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::data_points; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let points = data_points(vec![("Sales", 150), ("Revenue", 200)])?; +/// +/// let expected = serde_json::json!([ +/// { "label": "Sales", "value": 150 }, +/// { "label": "Revenue", "value": 200 } +/// ]); +/// +/// let json = serde_json::to_value(points).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +pub fn data_points(iter: Iter) -> Result, ValidationErrors> +where + Iter: IntoIterator, + S: Into, + N: Into, +{ + iter.into_iter().map(DataPoint::try_from).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = DataPoint { + label: Some("Sales".into()), + value: Some(Number::from(150)), + }; + + let val = DataPoint::builder() + .set_label(Some("Sales")) + .set_value(Some(150)) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = DataPoint::builder() + .label("Sales") + .value(150) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_try_from() { + let expected = DataPoint { + label: Some("Revenue".into()), + value: Some(Number::from(200)), + }; + + let val = DataPoint::try_from(("Revenue", 200)).unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_data_points_function() { + let expected = vec![ + DataPoint { + label: Some("Sales".into()), + value: Some(Number::from(150)), + }, + DataPoint { + label: Some("Revenue".into()), + value: Some(Number::from(200)), + }, + ]; + + let val = data_points(vec![("Sales", 150), ("Revenue", 200)]).unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_label_field() { + let err = DataPoint::builder().value(150).build().unwrap_err(); + assert_eq!(err.object(), "DataPoint"); + + let errors = err.field("label"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_label_field_less_than_20_characters_long() { + let err = DataPoint::builder() + .label("a".repeat(21)) + .value(150) + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataPoint"); + + let errors = err.field("label"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(20))); + } + + #[test] + fn it_requires_value_field() { + let err = DataPoint::builder().label("Sales").build().unwrap_err(); + assert_eq!(err.object(), "DataPoint"); + + let errors = err.field("value"); + assert!(errors.includes(ValidationErrorKind::Required)); + } +} diff --git a/slack-messaging/src/blocks/data_visualization/charts/data_series.rs b/slack-messaging/src/blocks/data_visualization/charts/data_series.rs new file mode 100644 index 0000000..9bab38f --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/data_series.rs @@ -0,0 +1,204 @@ +use super::DataPoint; + +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Data Series](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#data-series) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#data-series). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | name | String | Yes | Max length 20 characters | +/// | data | Vec<[DataPoint]> | Yes | Min 1, Max 20 items | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::{data_points, DataSeries}; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let series = DataSeries::builder() +/// .name("Sales") +/// .data(data_points(vec![ +/// ("Mon", 200), +/// ("Tue", 120), +/// ("Wed", 180), +/// ("Thu", 50), +/// ("Fri", 300) +/// ])?) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "name": "Sales", +/// "data": [ +/// { "label": "Mon", "value": 200 }, +/// { "label": "Tue", "value": 120 }, +/// { "label": "Wed", "value": 180 }, +/// { "label": "Thu", "value": 50 }, +/// { "label": "Fri", "value": 300 } +/// ] +/// }); +/// +/// let json = serde_json::to_value(series).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +pub struct DataSeries { + #[builder(validate("required", "text::max_20"))] + pub(crate) name: Option, + + #[builder( + push_item = "data_point", + validate("required", "list::not_empty", "list::max_item_20") + )] + pub(crate) data: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = DataSeries { + name: Some("Sales".to_string()), + data: Some(vec![ + point("Mon", 200), + point("Tue", 120), + point("Wed", 180), + point("Thu", 50), + point("Fri", 300), + ]), + }; + + let series = DataSeries::builder() + .set_name(Some("Sales")) + .set_data(Some(vec![ + point("Mon", 200), + point("Tue", 120), + point("Wed", 180), + point("Thu", 50), + point("Fri", 300), + ])) + .build() + .unwrap(); + + assert_eq!(series, expected); + + let series = DataSeries::builder() + .name("Sales") + .data(vec![ + point("Mon", 200), + point("Tue", 120), + point("Wed", 180), + point("Thu", 50), + point("Fri", 300), + ]) + .build() + .unwrap(); + + assert_eq!(series, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = DataSeries { + name: Some("Sales".to_string()), + data: Some(vec![ + point("Mon", 200), + point("Tue", 120), + ]), + }; + + let series = DataSeries::builder() + .name("Sales") + .data_point(point("Mon", 200)) + .data_point(point("Tue", 120)) + .build() + .unwrap(); + + assert_eq!(series, expected); + } + + #[test] + fn it_requires_name_field() { + let err = DataSeries::builder() + .data(vec![point("Mon", 200)]) + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataSeries"); + + let errors = err.field("name"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_name_field_less_than_20_characters_long() { + let err = DataSeries::builder() + .name("a".repeat(21)) + .data(vec![point("Mon", 200)]) + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataSeries"); + + let errors = err.field("name"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(20))); + } + + #[test] + fn it_requires_data_field() { + let err = DataSeries::builder() + .name("Sales") + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataSeries"); + + let errors = err.field("data"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_data_field_with_min_1_item() { + let err = DataSeries::builder() + .name("Sales") + .data(vec![] as Vec) + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataSeries"); + + let errors = err.field("data"); + assert!(errors.includes(ValidationErrorKind::EmptyArray)); + } + + #[test] + fn it_requires_data_field_with_max_20_items() { + let err = DataSeries::builder() + .name("Sales") + .data(vec![point("Mon", 200); 21]) + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataSeries"); + + let errors = err.field("data"); + assert!(errors.includes(ValidationErrorKind::MaxArraySize(20))); + } + + fn point(label: &str, value: i32) -> DataPoint { + (label, value).try_into().unwrap() + } +} diff --git a/slack-messaging/src/blocks/data_visualization/charts/line_chart.rs b/slack-messaging/src/blocks/data_visualization/charts/line_chart.rs new file mode 100644 index 0000000..9accd2f --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/line_chart.rs @@ -0,0 +1,309 @@ +use super::{AxisConfig, DataSeries, ValidateXYChart}; + +use crate::errors::ValidationErrorKind; +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Line chart +/// block](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#line) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#line). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | series | Vec<[DataSeries]> | Yes | Min 1, Max 6 items, Unique names, Labels must match axis categories | +/// | axis_config | [AxisConfig] | Yes | N/A | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::{data_points, DataSeries, LineChart, +/// AxisConfig}; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let line_chart = LineChart::builder() +/// .series(vec![ +/// DataSeries::builder() +/// .name("Website") +/// .data(data_points(vec![ +/// ("Week 1", 32000), +/// ("Week 2", 35000), +/// ("Week 3", 29000), +/// ("Week 4", 41000), +/// ("Week 5", 45000), +/// ])?) +/// .build()?, +/// DataSeries::builder() +/// .name("In-store") +/// .data(data_points(vec![ +/// ("Week 1", 17000), +/// ("Week 2", 20000), +/// ("Week 3", 15000), +/// ("Week 4", 22000), +/// ("Week 5", 30000), +/// ])?) +/// .build()?, +/// ]) +/// .axis_config( +/// AxisConfig::builder() +/// .categories(vec![ +/// "Week 1", +/// "Week 2", +/// "Week 3", +/// "Week 4", +/// "Week 5", +/// ]) +/// .x_label("Week") +/// .y_label("Paper Sales (USD)") +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "line", +/// "series": [ +/// { +/// "name": "Website", +/// "data": [ +/// { "label": "Week 1", "value": 32000 }, +/// { "label": "Week 2", "value": 35000 }, +/// { "label": "Week 3", "value": 29000 }, +/// { "label": "Week 4", "value": 41000 }, +/// { "label": "Week 5", "value": 45000 } +/// ] +/// }, +/// { +/// "name": "In-store", +/// "data": [ +/// { "label": "Week 1", "value": 17000 }, +/// { "label": "Week 2", "value": 20000 }, +/// { "label": "Week 3", "value": 15000 }, +/// { "label": "Week 4", "value": 22000 }, +/// { "label": "Week 5", "value": 30000 } +/// ] +/// } +/// ], +/// "axis_config": { +/// "categories": [ +/// "Week 1", +/// "Week 2", +/// "Week 3", +/// "Week 4", +/// "Week 5" +/// ], +/// "x_label": "Week", +/// "y_label": "Paper Sales (USD)" +/// } +/// }); +/// +/// let json = serde_json::to_value(line_chart).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[builder(validate = "validate")] +#[serde(tag = "type", rename = "line")] +pub struct LineChart { + #[builder( + push_item = "push_series", + validate("required", "list::not_empty", "list::max_item_6", "list::unique_series_names") + )] + pub(crate) series: Option>, + + #[builder(validate("required"))] + pub(crate) axis_config: Option, +} + +impl ValidateXYChart for LineChart { + fn series(&self) -> Option<&[DataSeries]> { + self.series.as_deref() + } + + fn axis_config(&self) -> Option<&AxisConfig> { + self.axis_config.as_ref() + } +} + +fn validate(val: &LineChart) -> Vec { + val.validate() +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::data_points; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = LineChart { + series: Some(vec![data_series("Pie")]), + axis_config: Some(axis_config()), + }; + + let val = LineChart::builder() + .set_series(Some(vec![data_series("Pie")])) + .set_axis_config(Some(axis_config())) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = LineChart::builder() + .series(vec![data_series("Pie")]) + .axis_config(axis_config()) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = LineChart { + series: Some(vec![data_series("Pie")]), + axis_config: Some(axis_config()), + }; + + let val = LineChart::builder() + .push_series(data_series("Pie")) + .axis_config(axis_config()) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_series_field() { + let err = LineChart::builder() + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "LineChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_series_field_to_have_at_least_one_item() { + let err = LineChart::builder() + .series(vec![] as Vec) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "LineChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::EmptyArray)); + } + + #[test] + fn it_requires_series_field_to_have_no_more_than_six_items() { + let err = LineChart::builder() + .series(vec![ + data_series("Series 1"), + data_series("Series 2"), + data_series("Series 3"), + data_series("Series 4"), + data_series("Series 5"), + data_series("Series 6"), + data_series("Series 7"), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "LineChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::MaxArraySize(6))); + } + + #[test] + fn it_requires_axis_config_field() { + let err = LineChart::builder() + .series(vec![data_series("Pie")]) + .build() + .unwrap_err(); + assert_eq!(err.object(), "LineChart"); + + let errors = err.field("axis_config"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_series_names_to_be_unique() { + let err = LineChart::builder() + .series(vec![ + data_series("Sales"), + data_series("Revenue"), + data_series("Sales"), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "LineChart"); + + let errors = err.field("series"); + assert!(errors.includes(ValidationErrorKind::UniqueSeriesName)); + } + + #[test] + fn it_requires_series_labels_to_match_axis_categories() { + let err = LineChart::builder() + .series(vec![ + data_series("Pie"), + DataSeries::builder() + .name("Cake") + .data(data_points(vec![ + ("Chocolate", 90), + ("Vanilla", 80), + ]).unwrap()) + .build() + .unwrap(), + ]) + .axis_config(axis_config()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "LineChart"); + + let errors = err.across_fields(); + assert!(errors.includes(ValidationErrorKind::DataPointLabelMatching)); + } + + fn data_series(name: &str) -> DataSeries { + DataSeries::builder() + .name(name) + .data(data_points(vec![ + ("Strawberry Rhubarb", 85), + ("Pumpkin", 70), + ]).unwrap()) + .build() + .unwrap() + } + + fn axis_config() -> AxisConfig { + AxisConfig::builder() + .categories(vec![ + "Strawberry Rhubarb", + "Pumpkin", + ]) + .x_label("Pies") + .y_label("Percentage of Tastiness") + .build() + .unwrap() + } +} diff --git a/slack-messaging/src/blocks/data_visualization/charts/mod.rs b/slack-messaging/src/blocks/data_visualization/charts/mod.rs new file mode 100644 index 0000000..cb93be8 --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/mod.rs @@ -0,0 +1,134 @@ +use crate::errors::ValidationErrorKind; +use std::collections::HashSet; + +/// Builders for creating charts and their components. +pub mod builders; + +mod area_chart; +mod axis_config; +mod bar_chart; +mod data_series; +mod data_point; +mod line_chart; +mod pie_chart; +mod segment; + +pub use area_chart::AreaChart; +pub use axis_config::AxisConfig; +pub use bar_chart::BarChart; +pub use data_point::{data_points, DataPoint}; +pub use data_series::DataSeries; +pub use line_chart::LineChart; +pub use pie_chart::PieChart; +pub use segment::{segments, Segment}; + +fn match_labels(series: &[DataSeries], config: &AxisConfig) -> bool { + let categories: HashSet<&str> = match config.categories.as_ref() { + Some(cats) => cats.iter().map(|s| s.as_str()).collect(), + None => HashSet::new(), + }; + + series.iter().all(|s| { + let labels: HashSet<&str> = match s.data.as_ref() { + Some(points) => points + .iter() + .flat_map(|p| p.label.as_deref()) + .collect(), + None => HashSet::new(), + }; + categories == labels + }) +} + +trait ValidateXYChart { + fn series(&self) -> Option<&[DataSeries]>; + fn axis_config(&self) -> Option<&AxisConfig>; + + fn validate(&self) -> Vec { + let mut errors = vec![]; + + if let (Some(series), Some(axis_config)) = (self.series(), self.axis_config()) + && !match_labels(series, axis_config) + { + errors.push(ValidationErrorKind::DataPointLabelMatching); + } + + errors + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod fn_match_labels { + use super::*; + + #[test] + fn it_returns_true_if_all_labels_match_categories() { + let series = vec![ + data_series(vec![ + ("Mon", 200), + ("Tue", 120), + ]), + data_series(vec![ + ("Mon", 180), + ("Tue", 50), + ]), + ]; + let config = axis_config(vec!["Mon", "Tue"]); + assert!(match_labels(&series, &config)); + } + + #[test] + fn it_returns_false_if_any_series_lacks_some_labels() { + let series = vec![ + data_series(vec![ + ("Mon", 200), + ("Tue", 120), + ("Wed", 250), + ]), + data_series(vec![ + ("Mon", 180), + ("Tue", 50), + ]), + ]; + let config = axis_config(vec!["Mon", "Tue", "Wed"]); + assert!(!match_labels(&series, &config)); + } + + #[test] + fn it_returns_false_if_any_series_has_additonal_labels() { + let series = vec![ + data_series(vec![ + ("Mon", 200), + ]), + data_series(vec![ + ("Mon", 180), + ("Tue", 120), + ]), + ]; + let config = axis_config(vec!["Mon"]); + assert!(!match_labels(&series, &config)); + } + + fn data_series(points: Vec<(&str, i32)>) -> DataSeries { + DataSeries::builder() + .name("Sales") + .data(data_points(points).unwrap()) + .build() + .unwrap() + } + + fn axis_config(categories: Vec<&str>) -> AxisConfig { + let categories: Vec = categories + .into_iter() + .map(String::from) + .collect(); + AxisConfig::builder() + .categories(categories) + .build() + .unwrap() + } + } +} diff --git a/slack-messaging/src/blocks/data_visualization/charts/pie_chart.rs b/slack-messaging/src/blocks/data_visualization/charts/pie_chart.rs new file mode 100644 index 0000000..9cb66d5 --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/pie_chart.rs @@ -0,0 +1,169 @@ +use super::Segment; + +use crate::validators::*; + +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// [Pie chart block](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#pie) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#pie). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | segments | Vec<[Segment]> | Yes | Min 1, Max 6 items | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::{segments, PieChart}; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let pie_chart = PieChart::builder() +/// .segments(segments(vec![ +/// ("Kit Kat", 45), +/// ("Twix", 28), +/// ("Crunch", 18), +/// ("Milky Way", 9), +/// ])?) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "pie", +/// "segments": [ +/// { "label": "Kit Kat", "value": 45 }, +/// { "label": "Twix", "value": 28 }, +/// { "label": "Crunch", "value": 18 }, +/// { "label": "Milky Way", "value": 9 } +/// ] +/// }); +/// +/// let json = serde_json::to_value(pie_chart).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[serde(tag = "type", rename = "pie")] +pub struct PieChart { + #[builder(push_item = "segment", validate("required", "list::not_empty", "list::max_item_6"))] + pub(crate) segments: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::segments; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = PieChart { + segments: Some(segments(vec![ + ("Segment 1", 10), + ("Segment 2", 20), + ]).unwrap()), + }; + + let val = PieChart::builder() + .set_segments(Some(segments(vec![ + ("Segment 1", 10), + ("Segment 2", 20), + ]).unwrap())) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = PieChart::builder() + .segments(segments(vec![ + ("Segment 1", 10), + ("Segment 2", 20), + ]).unwrap()) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_push_item_method() { + let expected = PieChart { + segments: Some(segments(vec![ + ("Segment 1", 10), + ("Segment 2", 20), + ]).unwrap()), + }; + + let val = PieChart::builder() + .segment( + Segment::builder() + .label("Segment 1") + .value(10) + .build() + .unwrap(), + ) + .segment( + Segment::builder() + .label("Segment 2") + .value(20) + .build() + .unwrap(), + ) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_segments_field() { + let err = PieChart::builder().build().unwrap_err(); + assert_eq!(err.object(), "PieChart"); + + let errors = err.field("segments"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_segments_list_size_more_than_0() { + let err = PieChart::builder() + .segments(vec![] as Vec) + .build() + .unwrap_err(); + assert_eq!(err.object(), "PieChart"); + + let errors = err.field("segments"); + assert!(errors.includes(ValidationErrorKind::EmptyArray)); + } + + #[test] + fn it_requires_segments_list_size_less_than_6() { + let segs = segments(vec![ + ("Segment 1", 10), + ("Segment 2", 20), + ("Segment 3", 30), + ("Segment 4", 40), + ("Segment 5", 50), + ("Segment 6", 60), + ("Segment 7", 70), + ]).unwrap(); + let err = PieChart::builder() + .segments(segs) + .build() + .unwrap_err(); + assert_eq!(err.object(), "PieChart"); + + let errors = err.field("segments"); + assert!(errors.includes(ValidationErrorKind::MaxArraySize(6))); + } +} diff --git a/slack-messaging/src/blocks/data_visualization/charts/segment.rs b/slack-messaging/src/blocks/data_visualization/charts/segment.rs new file mode 100644 index 0000000..6f07d18 --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/charts/segment.rs @@ -0,0 +1,208 @@ +use crate::errors::ValidationErrors; +use crate::validators::*; + +use serde::Serialize; +use serde_json::Number; +use slack_messaging_derive::Builder; + +/// [Segment](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#segment) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#segment). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | label | String | Yes | Max length 20 characters | +/// | value | [Number] | Yes | Greater than 0 | +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::Segment; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let seg = Segment::builder() +/// .label("Segment A") +/// .value(100) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "label": "Segment A", +/// "value": 100 +/// }); +/// +/// let json = serde_json::to_value(seg).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +pub struct Segment { + #[builder(validate("required", "text::max_20"))] + pub(crate) label: Option, + + #[builder(validate("required", "number::greater_than_zero"))] + pub(crate) value: Option, +} + +impl TryFrom<(S, N)> for Segment +where + S: Into, + N: Into, +{ + type Error = ValidationErrors; + + fn try_from(value: (S, N)) -> Result { + let (label, value) = value; + Segment::builder() + .label(label) + .value(value) + .build() + } +} + +/// Helper function to create multiple segments from an iterator of tuples. +/// +/// # Example +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::charts::segments; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let segs = segments(vec![("Segment A", 100), ("Segment B", 200)])?; +/// +/// let expected = serde_json::json!([ +/// { "label": "Segment A", "value": 100 }, +/// { "label": "Segment B", "value": 200 } +/// ]); +/// +/// let json = serde_json::to_value(segs).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +pub fn segments(iter: Iter) -> Result, ValidationErrors> +where + Iter: IntoIterator, + S: Into, + N: Into, +{ + iter.into_iter().map(Segment::try_from).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = Segment { + label: Some("Segment A".to_string()), + value: Some(Number::from(100)), + }; + + let val = Segment::builder() + .set_label(Some("Segment A")) + .set_value(Some(100)) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = Segment::builder() + .label("Segment A") + .value(100) + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_try_from_tuple() { + let expected = Segment { + label: Some("Segment A".to_string()), + value: Some(Number::from(100)), + }; + + let val = Segment::try_from(("Segment A", 100)).unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_implements_segments_function() { + let expected = vec![ + Segment { + label: Some("Segment A".to_string()), + value: Some(Number::from(100)), + }, + Segment { + label: Some("Segment B".to_string()), + value: Some(Number::from(200)), + }, + ]; + + let val = segments(vec![("Segment A", 100), ("Segment B", 200)]).unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_label_field() { + let err = Segment::builder().value(100).build().unwrap_err(); + assert_eq!(err.object(), "Segment"); + + let errors = err.field("label"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_label_field_less_than_20_characters_long() { + let err = Segment::builder() + .label("a".repeat(21)) + .value(100) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Segment"); + + let errors = err.field("label"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(20))); + } + + #[test] + fn it_requires_value_field() { + let err = Segment::builder().label("Segment A").build().unwrap_err(); + assert_eq!(err.object(), "Segment"); + + let errors = err.field("value"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_value_field_greater_than_zero() { + let err = Segment::builder() + .label("Segment A") + .value(0) + .build() + .unwrap_err(); + assert_eq!(err.object(), "Segment"); + + let errors = err.field("value"); + assert!(errors.includes(ValidationErrorKind::MustBeGreaterThanZero)); + } +} diff --git a/slack-messaging/src/blocks/data_visualization/mod.rs b/slack-messaging/src/blocks/data_visualization/mod.rs new file mode 100644 index 0000000..ab01f61 --- /dev/null +++ b/slack-messaging/src/blocks/data_visualization/mod.rs @@ -0,0 +1,484 @@ +use crate::validators::*; + +use paste::paste; +use serde::Serialize; +use slack_messaging_derive::Builder; + +/// Charts and their related components. +pub mod charts; + +/// Re-export of all chart types and related components. +pub mod prelude { + pub use super::charts::*; + pub use super::{Chart, DataVisualization}; +} + +use charts::{AreaChart, BarChart, LineChart, PieChart}; + +/// Each chart type supported by the chart field of the [DataVisualization] +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum Chart { + /// [Pie chart](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#pie) + Pie(PieChart), + + /// [Bar chart](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#bar) + Bar(BarChart), + + /// [Area chart](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#area) + Area(AreaChart), + + /// [Line chart](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block#line) + Line(LineChart), +} + +macro_rules! impl_chart_from { + ($($var:tt,)*) => { + paste! { + $( + impl From<[<$var Chart>]> for Chart { + fn from(value: [<$var Chart>]) -> Self { + Chart::$var(value) + } + } + )* + } + }; +} + +impl_chart_from!(Pie, Bar, Area, Line,); + +/// [Data visualization +/// block](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block/) +/// representation. +/// +/// # Fields and Validations +/// +/// For more details, see the [official +/// documentation](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block). +/// +/// | Field | Type | Required | Validation | +/// |-------|------|----------|------------| +/// | title | String | Yes | Max length 50 characters | +/// | chart | [Chart] | Yes | N/A | +/// | block_id | String | No | Max length 255 characters | +/// +/// # Example 1) Pie chart +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::prelude::*; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let data_viz = DataVisualization::builder() +/// .title("My Favorite Candy Bars") +/// .chart( +/// PieChart::builder() +/// .segments(segments(vec![ +/// ("Kit Kat", 45), +/// ("Twix", 28), +/// ("Crunch", 18), +/// ("Milky Way", 9), +/// ])?) +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "data_visualization", +/// "title": "My Favorite Candy Bars", +/// "chart": { +/// "type": "pie", +/// "segments": [ +/// { "label": "Kit Kat", "value": 45 }, +/// { "label": "Twix", "value": 28 }, +/// { "label": "Crunch", "value": 18 }, +/// { "label": "Milky Way", "value": 9 } +/// ] +/// } +/// }); +/// +/// let json = serde_json::to_value(data_viz).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +/// +/// # Example 2) Bar chart +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::prelude::*; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let data_viz = DataVisualization::builder() +/// .title("My Favorite Pies by Percentage of Tastiness") +/// .chart( +/// BarChart::builder() +/// .series(vec![ +/// DataSeries::builder() +/// .name("Pies") +/// .data(data_points(vec![ +/// ("Strawberry Rhubarb", 85), +/// ("Pumpkin", 70), +/// ("Lemon Meringue", 72), +/// ("Blueberry", 90), +/// ("Key Lime", 56), +/// ])?) +/// .build()? +/// ]) +/// .axis_config( +/// AxisConfig::builder() +/// .categories(vec![ +/// "Strawberry Rhubarb", +/// "Pumpkin", +/// "Lemon Meringue", +/// "Blueberry", +/// "Key Lime", +/// ]) +/// .x_label("Pies") +/// .y_label("Percentage of Tastiness") +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "data_visualization", +/// "title": "My Favorite Pies by Percentage of Tastiness", +/// "chart": { +/// "type": "bar", +/// "series": [ +/// { +/// "name": "Pies", +/// "data": [ +/// { "label": "Strawberry Rhubarb", "value": 85 }, +/// { "label": "Pumpkin", "value": 70 }, +/// { "label": "Lemon Meringue", "value": 72 }, +/// { "label": "Blueberry", "value": 90 }, +/// { "label": "Key Lime", "value": 56 } +/// ] +/// } +/// ], +/// "axis_config": { +/// "categories": [ +/// "Strawberry Rhubarb", +/// "Pumpkin", +/// "Lemon Meringue", +/// "Blueberry", +/// "Key Lime" +/// ], +/// "x_label": "Pies", +/// "y_label": "Percentage of Tastiness" +/// } +/// } +/// }); +/// +/// let json = serde_json::to_value(data_viz).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +/// +/// # Example 3) Area chart +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::prelude::*; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let data_viz = DataVisualization::builder() +/// .title("Daily Active Users") +/// .chart( +/// AreaChart::builder() +/// .series(vec![ +/// DataSeries::builder() +/// .name("Pied Piper Free Tier") +/// .data(data_points(vec![ +/// ("Mon", 12000), +/// ("Tue", 13500), +/// ("Wed", 15200), +/// ("Thu", 14800), +/// ("Fri", 16400), +/// ])?) +/// .build()?, +/// DataSeries::builder() +/// .name("Pied Piper Paid Tier") +/// .data(data_points(vec![ +/// ("Mon", 4500), +/// ("Tue", 4800), +/// ("Wed", 5100), +/// ("Thu", 5600), +/// ("Fri", 6200), +/// ])?) +/// .build()?, +/// ]) +/// .axis_config( +/// AxisConfig::builder() +/// .categories(vec!["Mon", "Tue", "Wed", "Thu", "Fri"]) +/// .x_label("Day") +/// .y_label("Users") +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "data_visualization", +/// "title": "Daily Active Users", +/// "chart": { +/// "type": "area", +/// "series": [ +/// { +/// "name": "Pied Piper Free Tier", +/// "data": [ +/// { "label": "Mon", "value": 12000 }, +/// { "label": "Tue", "value": 13500 }, +/// { "label": "Wed", "value": 15200 }, +/// { "label": "Thu", "value": 14800 }, +/// { "label": "Fri", "value": 16400 } +/// ] +/// }, +/// { +/// "name": "Pied Piper Paid Tier", +/// "data": [ +/// { "label": "Mon", "value": 4500 }, +/// { "label": "Tue", "value": 4800 }, +/// { "label": "Wed", "value": 5100 }, +/// { "label": "Thu", "value": 5600 }, +/// { "label": "Fri", "value": 6200 } +/// ] +/// } +/// ], +/// "axis_config": { +/// "categories": ["Mon", "Tue", "Wed", "Thu", "Fri"], +/// "x_label": "Day", +/// "y_label": "Users" +/// } +/// } +/// }); +/// +/// let json = serde_json::to_value(data_viz).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +/// +/// # Example 4) Line chart +/// +/// ``` +/// use slack_messaging::blocks::data_visualization::prelude::*; +/// # use std::error::Error; +/// +/// # fn try_main() -> Result<(), Box> { +/// let data_viz = DataVisualization::builder() +/// .title("Weekly Paper Sales") +/// .chart( +/// LineChart::builder() +/// .series(vec![ +/// DataSeries::builder() +/// .name("Website") +/// .data(data_points(vec![ +/// ("Week 1", 32000), +/// ("Week 2", 35000), +/// ("Week 3", 29000), +/// ("Week 4", 41000), +/// ("Week 5", 45000), +/// ])?) +/// .build()?, +/// DataSeries::builder() +/// .name("In-store") +/// .data(data_points(vec![ +/// ("Week 1", 17000), +/// ("Week 2", 20000), +/// ("Week 3", 15000), +/// ("Week 4", 22000), +/// ("Week 5", 30000), +/// ])?) +/// .build()?, +/// ]) +/// .axis_config( +/// AxisConfig::builder() +/// .categories(vec![ +/// "Week 1", +/// "Week 2", +/// "Week 3", +/// "Week 4", +/// "Week 5", +/// ]) +/// .x_label("Week") +/// .y_label("Paper Sales (USD)") +/// .build()? +/// ) +/// .build()? +/// ) +/// .build()?; +/// +/// let expected = serde_json::json!({ +/// "type": "data_visualization", +/// "title": "Weekly Paper Sales", +/// "chart": { +/// "type": "line", +/// "series": [ +/// { +/// "name": "Website", +/// "data": [ +/// { "label": "Week 1", "value": 32000 }, +/// { "label": "Week 2", "value": 35000 }, +/// { "label": "Week 3", "value": 29000 }, +/// { "label": "Week 4", "value": 41000 }, +/// { "label": "Week 5", "value": 45000 } +/// ] +/// }, +/// { +/// "name": "In-store", +/// "data": [ +/// { "label": "Week 1", "value": 17000 }, +/// { "label": "Week 2", "value": 20000 }, +/// { "label": "Week 3", "value": 15000 }, +/// { "label": "Week 4", "value": 22000 }, +/// { "label": "Week 5", "value": 30000 } +/// ] +/// } +/// ], +/// "axis_config": { +/// "categories": ["Week 1", "Week 2", "Week 3", "Week 4", "Week 5"], +/// "x_label": "Week", +/// "y_label": "Paper Sales (USD)" +/// } +/// } +/// }); +/// +/// let json = serde_json::to_value(data_viz).unwrap(); +/// +/// assert_eq!(json, expected); +/// # Ok(()) +/// # } +/// # fn main() { +/// # try_main().unwrap() +/// # } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq, Builder)] +#[serde(tag = "type", rename = "data_visualization")] +pub struct DataVisualization { + #[builder(validate("required", "text::max_50"))] + pub(crate) title: Option, + + #[builder(validate("required"))] + pub(crate) chart: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(validate("text::max_255"))] + pub(crate) block_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use super::prelude::*; + use crate::errors::*; + + #[test] + fn it_implements_builder() { + let expected = DataVisualization { + title: Some("My Favorite Candy Bars".into()), + chart: Some(chart()), + block_id: Some("data_viz_0".into()), + }; + + let val = DataVisualization::builder() + .set_title(Some("My Favorite Candy Bars")) + .set_chart(Some(chart())) + .set_block_id(Some("data_viz_0")) + .build() + .unwrap(); + + assert_eq!(val, expected); + + let val = DataVisualization::builder() + .title("My Favorite Candy Bars") + .chart(chart()) + .block_id("data_viz_0") + .build() + .unwrap(); + + assert_eq!(val, expected); + } + + #[test] + fn it_requires_title() { + let err = DataVisualization::builder() + .chart(chart()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataVisualization"); + + let errors = err.field("title"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_title_field_to_be_50_characters_or_fewer() { + let err = DataVisualization::builder() + .title("a".repeat(51)) + .chart(chart()) + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataVisualization"); + + let errors = err.field("title"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(50))); + } + + #[test] + fn it_requires_chart() { + let err = DataVisualization::builder() + .title("My Favorite Candy Bars") + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataVisualization"); + + let errors = err.field("chart"); + assert!(errors.includes(ValidationErrorKind::Required)); + } + + #[test] + fn it_requires_block_id_field_to_be_255_characters_or_fewer() { + let err = DataVisualization::builder() + .title("My Favorite Candy Bars") + .chart(chart()) + .block_id("a".repeat(256)) + .build() + .unwrap_err(); + assert_eq!(err.object(), "DataVisualization"); + + let errors = err.field("block_id"); + assert!(errors.includes(ValidationErrorKind::MaxTextLength(255))); + } + + fn chart() -> Chart { + Chart::Pie(PieChart::builder() + .segments(segments(vec![ + ("Kit Kat", 45), + ("Twix", 28), + ("Crunch", 18), + ("Milky Way", 9), + ]).unwrap()) + .build() + .unwrap()) + } +} diff --git a/slack-messaging/src/blocks/elements/icon_button.rs b/slack-messaging/src/blocks/elements/icon_button.rs index c7e46c4..664efba 100644 --- a/slack-messaging/src/blocks/elements/icon_button.rs +++ b/slack-messaging/src/blocks/elements/icon_button.rs @@ -118,7 +118,7 @@ mod tests { .set_value(Some("delete_item")) .set_confirm(Some(confirm())) .set_accessibility_label(Some("Delete!")) - .set_visible_to_user_ids(Some(vec!["USER0".into(), "USER1".into()])) + .set_visible_to_user_ids(Some(vec!["USER0", "USER1"])) .build() .unwrap(); @@ -131,7 +131,7 @@ mod tests { .value("delete_item") .confirm(confirm()) .accessibility_label("Delete!") - .visible_to_user_ids(vec!["USER0".into(), "USER1".into()]) + .visible_to_user_ids(vec!["USER0", "USER1"]) .build() .unwrap(); diff --git a/slack-messaging/src/blocks/elements/multi_select_menus/conversations.rs b/slack-messaging/src/blocks/elements/multi_select_menus/conversations.rs index 941a669..307b8e3 100644 --- a/slack-messaging/src/blocks/elements/multi_select_menus/conversations.rs +++ b/slack-messaging/src/blocks/elements/multi_select_menus/conversations.rs @@ -116,7 +116,7 @@ mod tests { let val = MultiSelectMenuConversations::builder() .set_action_id(Some("multi_select_0")) - .set_initial_conversations(Some(vec!["foo".into(), "bar".into()])) + .set_initial_conversations(Some(vec!["foo", "bar"])) .set_default_to_current_conversation(Some(false)) .set_confirm(Some(confirm())) .set_max_selected_items(Some(2)) @@ -130,7 +130,7 @@ mod tests { let val = MultiSelectMenuConversations::builder() .action_id("multi_select_0") - .initial_conversations(vec!["foo".into(), "bar".into()]) + .initial_conversations(vec!["foo", "bar"]) .default_to_current_conversation(false) .confirm(confirm()) .max_selected_items(2) diff --git a/slack-messaging/src/blocks/elements/multi_select_menus/public_channels.rs b/slack-messaging/src/blocks/elements/multi_select_menus/public_channels.rs index 6bf8e98..4f2ad50 100644 --- a/slack-messaging/src/blocks/elements/multi_select_menus/public_channels.rs +++ b/slack-messaging/src/blocks/elements/multi_select_menus/public_channels.rs @@ -106,7 +106,7 @@ mod tests { let val = MultiSelectMenuPublicChannels::builder() .set_action_id(Some("multi_select_0")) - .set_initial_channels(Some(vec!["foo".into(), "bar".into()])) + .set_initial_channels(Some(vec!["foo", "bar"])) .set_confirm(Some(confirm())) .set_max_selected_items(Some(2)) .set_focus_on_load(Some(true)) @@ -118,7 +118,7 @@ mod tests { let val = MultiSelectMenuPublicChannels::builder() .action_id("multi_select_0") - .initial_channels(vec!["foo".into(), "bar".into()]) + .initial_channels(vec!["foo", "bar"]) .confirm(confirm()) .max_selected_items(2) .focus_on_load(true) diff --git a/slack-messaging/src/blocks/elements/multi_select_menus/users.rs b/slack-messaging/src/blocks/elements/multi_select_menus/users.rs index 6103c02..b7d480c 100644 --- a/slack-messaging/src/blocks/elements/multi_select_menus/users.rs +++ b/slack-messaging/src/blocks/elements/multi_select_menus/users.rs @@ -106,7 +106,7 @@ mod tests { let val = MultiSelectMenuUsers::builder() .set_action_id(Some("multi_select_0")) - .set_initial_users(Some(vec!["USER0".into(), "USER1".into()])) + .set_initial_users(Some(vec!["USER0", "USER1"])) .set_confirm(Some(confirm())) .set_max_selected_items(Some(2)) .set_focus_on_load(Some(true)) @@ -118,7 +118,7 @@ mod tests { let val = MultiSelectMenuUsers::builder() .action_id("multi_select_0") - .initial_users(vec!["USER0".into(), "USER1".into()]) + .initial_users(vec!["USER0", "USER1"]) .confirm(confirm()) .max_selected_items(2) .focus_on_load(true) diff --git a/slack-messaging/src/blocks/mod.rs b/slack-messaging/src/blocks/mod.rs index f27b9c4..06e639b 100644 --- a/slack-messaging/src/blocks/mod.rs +++ b/slack-messaging/src/blocks/mod.rs @@ -8,6 +8,8 @@ pub mod elements; pub mod rich_text; /// Module for building [Table] block. pub mod table; +/// Module for building [DataVisualization] block. +pub mod data_visualization; /// Module for building [DataTable] block. pub mod data_table; @@ -35,6 +37,7 @@ pub use carousel::Carousel; pub use context::{Context, ContextElement}; pub use context_actions::{ContextActions, ContextActionsElement}; pub use data_table::DataTable; +pub use data_visualization::DataVisualization; pub use divider::Divider; pub use file::{File, FileSource}; pub use header::Header; @@ -74,6 +77,11 @@ pub enum Block { /// [Data table block](https://docs.slack.dev/reference/block-kit/blocks/data-table-block) representation DataTable(Box), + + /// [Data visualization + /// block](https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block) + /// representation + DataVisualization(Box), /// [Divider block](https://docs.slack.dev/reference/block-kit/blocks/divider-block) representation Divider(Box), @@ -133,6 +141,7 @@ block_from! { Context, ContextActions, DataTable, + DataVisualization, Divider, File, Header, diff --git a/slack-messaging/src/blocks/rich_text/mod.rs b/slack-messaging/src/blocks/rich_text/mod.rs index cb07dfd..b248ecc 100644 --- a/slack-messaging/src/blocks/rich_text/mod.rs +++ b/slack-messaging/src/blocks/rich_text/mod.rs @@ -179,7 +179,7 @@ mod tests { let val = RichText::builder() .set_block_id(Some("rich_text_0")) .set_elements(Some(vec![ - section(vec![el_text("foo"), el_emoji("var")]).into(), + section(vec![el_text("foo"), el_emoji("var")]), ])) .build() .unwrap(); @@ -188,7 +188,7 @@ mod tests { let val = RichText::builder() .block_id("rich_text_0") - .elements(vec![section(vec![el_text("foo"), el_emoji("var")]).into()]) + .elements(vec![section(vec![el_text("foo"), el_emoji("var")])]) .build() .unwrap(); diff --git a/slack-messaging/src/blocks/section.rs b/slack-messaging/src/blocks/section.rs index f4f0361..d08c980 100644 --- a/slack-messaging/src/blocks/section.rs +++ b/slack-messaging/src/blocks/section.rs @@ -255,7 +255,7 @@ mod tests { .set_fields(Some(vec![ plain_text("bar").into(), mrkdwn_text("baz").into(), - ])) + ] as Vec)) .set_accessory(Some(btn("btn0", "val0"))) .set_expand(Some(true)) .build() @@ -266,7 +266,10 @@ mod tests { let val = Section::builder() .text(mrkdwn_text("foo")) .block_id("section_0") - .fields(vec![plain_text("bar").into(), mrkdwn_text("baz").into()]) + .fields(vec![ + plain_text("bar").into(), + mrkdwn_text("baz").into(), + ] as Vec) .accessory(btn("btn0", "val0")) .expand(true) .build() diff --git a/slack-messaging/src/composition_objects/conversation_filter.rs b/slack-messaging/src/composition_objects/conversation_filter.rs index e2b62ae..719f2b4 100644 --- a/slack-messaging/src/composition_objects/conversation_filter.rs +++ b/slack-messaging/src/composition_objects/conversation_filter.rs @@ -158,7 +158,7 @@ mod tests { #[test] fn it_requires_include_field_not_empty() { let err = ConversationFilter::builder() - .include(vec![]) + .include(vec![] as Vec) .build() .unwrap_err(); assert_eq!(err.object(), "ConversationFilter"); diff --git a/slack-messaging/src/composition_objects/dispatch_action_configuration.rs b/slack-messaging/src/composition_objects/dispatch_action_configuration.rs index 55a7884..ee9570a 100644 --- a/slack-messaging/src/composition_objects/dispatch_action_configuration.rs +++ b/slack-messaging/src/composition_objects/dispatch_action_configuration.rs @@ -41,7 +41,7 @@ use slack_messaging_derive::Builder; /// /// // If your object has any validation errors, the build method returns Result::Err /// let config = DispatchActionConfiguration::builder() -/// .trigger_actions_on(vec![]) +/// .trigger_actions_on(vec![] as Vec) /// .build(); /// assert!(config.is_err()); /// # Ok(()) @@ -113,7 +113,7 @@ mod tests { #[test] fn it_requires_trigger_actions_on_field_not_empty() { let err = DispatchActionConfiguration::builder() - .trigger_actions_on(vec![]) + .trigger_actions_on(vec![] as Vec) .build() .unwrap_err(); assert_eq!(err.object(), "DispatchActionConfiguration"); diff --git a/slack-messaging/src/errors.rs b/slack-messaging/src/errors.rs index dfd9984..443af0b 100644 --- a/slack-messaging/src/errors.rs +++ b/slack-messaging/src/errors.rs @@ -40,6 +40,10 @@ pub enum ValidationErrorKind { #[error("min value is `{0}")] MinIntegerValue(i64), + /// Field must be greater than zero. + #[error("value must be greater than zero")] + MustBeGreaterThanZero, + /// Both fields are provided but only one is allowed. #[error("cannot provide both {0} and {1}")] ExclusiveField(&'static str, &'static str), @@ -58,6 +62,14 @@ pub enum ValidationErrorKind { /// Rich text block should have exactly one element. #[error("rich text block should have exactly one element")] RichTextSingleElement, + + /// Each series within a chart must have a unique name. + #[error("each series within a chart must have a unique name")] + UniqueSeriesName, + + /// Every data point label in every series must match a value in axis config categories. + #[error("every data point label in every series must match a value in axis config categories")] + DataPointLabelMatching, } /// Validation error from single field or across fields. diff --git a/slack-messaging/src/message.rs b/slack-messaging/src/message.rs index 44d561d..741318d 100644 --- a/slack-messaging/src/message.rs +++ b/slack-messaging/src/message.rs @@ -172,7 +172,7 @@ mod tests { .set_blocks(Some(vec![ header("this is a header block").into(), section("this is a section block").into(), - ])) + ] as Vec)) .set_thread_ts(Some("thread ts")) .set_mrkdwn(Some(true)) .set_response_type(Some("response type")) @@ -189,7 +189,7 @@ mod tests { .blocks(vec![ header("this is a header block").into(), section("this is a section block").into(), - ]) + ] as Vec) .thread_ts("thread ts") .mrkdwn(true) .response_type("response type") diff --git a/slack-messaging/src/validators/list.rs b/slack-messaging/src/validators/list.rs index 6e93043..0bf1f76 100644 --- a/slack-messaging/src/validators/list.rs +++ b/slack-messaging/src/validators/list.rs @@ -1,5 +1,6 @@ use super::*; use crate::composition_objects::TextExt; +use crate::blocks::data_visualization::charts::DataSeries; use paste::paste; @@ -40,7 +41,7 @@ macro_rules! impl_max_item { } } -impl_max_item!(5, 10, 20, 25, 50, 100, 101); +impl_max_item!(5, 6, 10, 20, 25, 50, 100, 101); macro_rules! impl_min_item { ($($e:expr),*) => { @@ -67,6 +68,25 @@ pub(crate) fn each_text_max_2000(value: List) -> List { }) } +pub(crate) fn each_max_20_chars(value: List) -> List { + inner_validator(value, ValidationErrorKind::MaxTextLength(20), |l| { + l.iter().any(|s| s.len() > 20) + }) +} + +pub(crate) fn unique_series_names(value: List) -> List { + inner_validator(value, ValidationErrorKind::UniqueSeriesName, |l| { + let mut names = std::collections::HashSet::new(); + l.iter().any(|series| { + if let Some(name) = series.name.as_ref() { + !names.insert(name) + } else { + false + } + }) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -166,4 +186,67 @@ mod tests { each_text_max_2000(Value::new(Some(list))) } } + + mod fn_each_max_20_chars { + use super::*; + + #[test] + fn it_passes_if_the_all_item_length_is_less_than_20() { + let list = vec!["a".repeat(20), "foobar".into()]; + let result = test(list); + assert!(result.errors.is_empty()); + } + + #[test] + fn it_sets_an_error_if_at_least_one_item_length_is_more_than_20() { + let list = vec!["a".repeat(21), "foobar".into()]; + let result = test(list); + assert_eq!( + result.errors, + vec![ValidationErrorKind::MaxTextLength(20)] + ); + } + + fn test(list: Vec) -> List { + each_max_20_chars(Value::new(Some(list))) + } + } + + mod fn_unique_series_names { + use super::*; + use crate::blocks::data_visualization::charts::data_points; + + #[test] + fn it_passes_if_all_series_names_are_unique() { + let list = vec!["Series 1", "Series 2", "Series 3"]; + let result = test(list); + assert!(result.errors.is_empty()); + } + + #[test] + fn it_sets_an_error_if_at_least_one_series_name_is_duplicated() { + let list = vec!["Series 1", "Series 2", "Series 1"]; + let result = test(list); + assert_eq!( + result.errors, + vec![ValidationErrorKind::UniqueSeriesName] + ); + } + + fn test(list: Vec<&str>) -> List { + let series: Vec = list.into_iter().map(data_series).collect(); + unique_series_names(Value::new(Some(series))) + } + + fn data_series(name: &str) -> DataSeries { + DataSeries::builder() + .name(name) + .data(data_points(vec![ + ("Mon", 200), + ("Tue", 120), + ]).unwrap()) + .build() + .unwrap() + } + } } diff --git a/slack-messaging/src/validators/mod.rs b/slack-messaging/src/validators/mod.rs index ff968bf..64a36f9 100644 --- a/slack-messaging/src/validators/mod.rs +++ b/slack-messaging/src/validators/mod.rs @@ -3,6 +3,7 @@ use crate::value::Value; pub(crate) mod integer; pub(crate) mod list; +pub(crate) mod number; pub(crate) mod rich_text; pub(crate) mod text; pub(crate) mod text_object; diff --git a/slack-messaging/src/validators/number.rs b/slack-messaging/src/validators/number.rs new file mode 100644 index 0000000..b7d6980 --- /dev/null +++ b/slack-messaging/src/validators/number.rs @@ -0,0 +1,98 @@ +use super::*; + +type Number = Value; + +fn inner_validator( + mut value: Number, + error: ValidationErrorKind, + predicate: impl Fn(&serde_json::Number) -> bool, +) -> Number { + if value.inner_ref().is_some_and(predicate) { + value.push(error); + } + value +} + +pub(crate) fn greater_than_zero(value: Number) -> Number { + inner_validator(value, ValidationErrorKind::MustBeGreaterThanZero, |v| { + if let Some(n) = v.as_i64() { + n <= 0 + } else if let Some(n) = v.as_u64() { + n == 0 + } else if let Some(n) = v.as_f64() { + n <= 0.0 + } else { + unreachable!("serde_json::Number should be either i64, u64, or f64") + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod fn_greater_than_zero { + use super::*; + + #[test] + fn it_sets_an_error_if_the_value_is_less_than_or_equal_to_zero() { + let result = subject_u64(0); + assert_eq!( + result.errors, + vec![ValidationErrorKind::MustBeGreaterThanZero] + ); + + let result = subject_f64(0.0); + assert_eq!( + result.errors, + vec![ValidationErrorKind::MustBeGreaterThanZero] + ); + + let result = subject_f64(-0.1); + assert_eq!( + result.errors, + vec![ValidationErrorKind::MustBeGreaterThanZero] + ); + + let result = subject_i64(0); + assert_eq!( + result.errors, + vec![ValidationErrorKind::MustBeGreaterThanZero] + ); + + let result = subject_i64(-1); + assert_eq!( + result.errors, + vec![ValidationErrorKind::MustBeGreaterThanZero] + ); + } + + #[test] + fn it_passes_if_the_value_is_greater_than_zero() { + let result = subject_u64(1); + assert!(result.errors.is_empty()); + + let result = subject_i64(1); + assert!(result.errors.is_empty()); + + let result = subject_f64(0.1); + assert!(result.errors.is_empty()); + } + + fn subject_u64(n: u64) -> Number { + subject(n.into()) + } + + fn subject_i64(n: i64) -> Number { + subject(n.into()) + } + + fn subject_f64(n: f64) -> Number { + subject(serde_json::Number::from_f64(n).unwrap()) + } + + fn subject(num: serde_json::Number) -> Number { + greater_than_zero(Value::new(Some(num))) + } + } +} diff --git a/slack-messaging/src/validators/text.rs b/slack-messaging/src/validators/text.rs index 8a36c19..0b31fa6 100644 --- a/slack-messaging/src/validators/text.rs +++ b/slack-messaging/src/validators/text.rs @@ -66,7 +66,7 @@ macro_rules! impl_max { } } -impl_max!(50, 75, 150, 255, 2000, 3000, 12000); +impl_max!(20, 50, 75, 150, 255, 2000, 3000, 12000); pub(crate) fn min_1(value: Text) -> Text { inner_validator(value, ValidationErrorKind::MinTextLength(1), |v| {