Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: overflow menu #129

Merged
merged 4 commits into from
May 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Makefile.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ args = [ "watch"
, "-x", "make -t test"
]

[tasks.cdd] # "compile" driven development?
install_crate = "cargo-watch"
command = "cargo"
args = [ "watch"
, "--watch", "./src"
, "--watch", "./tests"
, "-x", "check"
, "-x", "make fmt"
]

[tasks.check-fmt]
toolchain = "nightly"
command = "cargo"
Expand Down
4 changes: 2 additions & 2 deletions src/block_elements/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use crate::{text, val_helpr::ValidationResult};
/// [Actions 🔗]: https://api.slack.com/reference/block-kit/blocks#actions
/// [guide to enabling interactivity 🔗]: https://api.slack.com/interactivity/handling
#[derive(Validate, Clone, Debug, Deserialize, Hash, PartialEq, Serialize)]
pub struct Contents {
pub struct Button {
#[validate(custom = "validate::text")]
text: text::Text,

Expand All @@ -47,7 +47,7 @@ pub struct Contents {
confirm: Option<()>, // FIX: doesn't exist yet
}

impl Contents {
impl Button {
/// Create a `button::Contents` from a text label and ID for your app
/// to be able to identify what was pressed.
///
Expand Down
23 changes: 15 additions & 8 deletions src/block_elements/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ use serde::{Deserialize, Serialize};

use crate::{convert, val_helpr::ValidationResult};

pub mod radio;
pub use radio::Radio;

pub mod button;
pub use button::Contents as Button;

pub mod overflow;
pub mod radio;
pub mod select;
pub use select::Select;

pub mod text_input;

#[doc(inline)]
pub use button::Button;
#[doc(inline)]
pub use overflow::Overflow;
#[doc(inline)]
pub use radio::Radio;
#[doc(inline)]
pub use select::Select;
#[doc(inline)]
pub use text_input::TextInput;

/// # Block Elements - interactive components
Expand All @@ -36,7 +41,7 @@ pub enum BlockElement<'a> {
DatePicker,
Image,
MultiSelect,
OverflowMenu,
Overflow(Overflow<'a>),
RadioButtons(Radio<'a>),

#[serde(rename = "plain_text_input")]
Expand Down Expand Up @@ -68,6 +73,7 @@ impl<'a> BlockElement<'a> {
| Self::SelectExternal(cts) => cts.validate(),
| Self::SelectStatic(cts) => cts.validate(),
| Self::RadioButtons(cts) => cts.validate(),
| Self::Overflow(cts) => cts.validate(),
| rest => todo!("validation not implemented for {:?}", rest),
}
}
Expand All @@ -76,6 +82,7 @@ impl<'a> BlockElement<'a> {
convert!(impl From<Button> for BlockElement<'static> => |b| BlockElement::Button(b));
convert!(impl<'a> From<Radio<'a>> for BlockElement<'a> => |b| BlockElement::RadioButtons(b));
convert!(impl<'a> From<TextInput<'a>> for BlockElement<'a> => |t| BlockElement::TextInput(t));
convert!(impl<'a> From<Overflow<'a>> for BlockElement<'a> => |t| BlockElement::Overflow(t));

convert!(impl<'a> From<Select<'a>> for BlockElement<'a>
=> |s| match s {
Expand Down
236 changes: 236 additions & 0 deletions src/block_elements/overflow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
use std::borrow::Cow;

use serde::{Deserialize as De, Serialize as Ser};
use validator::Validate;

use crate::{compose::{opt::AllowUrl, Confirm, Opt},
text,
val_helpr::*};

type MyOpt<'a> = Opt<'a, text::Plain, AllowUrl>;

/// # Overflow Menu
///
/// This is like a cross between a button and a select menu -
/// when a user clicks on this overflow button,
/// they will be presented with a list of options to choose from.
///
/// Unlike the select menu, there is no typeahead field,
/// and the button always appears with an ellipsis ("…"),
/// rather than customisable text.
///
/// [slack api docs 🔗]
///
/// Works in [blocks 🔗]: Section, Actions
///
/// [slack api docs 🔗]: https://api.slack.com/reference/block-kit/block-elements#overflow
/// [blocks 🔗]: https://api.slack.com/reference/block-kit/blocks
#[derive(Clone, Debug, Hash, PartialEq, Ser, De, Validate)]
pub struct Overflow<'a> {
#[validate(length(max = 255))]
action_id: Cow<'a, str>,

#[validate(custom = "validate_options")]
// TODO: validate opts contained in cow
options: Cow<'a, [MyOpt<'a>]>,

#[validate]
confirm: Option<Confirm>,
}

impl<'a> Overflow<'a> {
/// Construct a new Overflow Menu.
///
/// # Example
/// See example of `build::OverflowBuilder`
pub fn builder() -> build::OverflowBuilderInit<'a> {
build::OverflowBuilderInit::new()
}

/// Validate that this select element agrees with Slack's model requirements
///
/// # Errors
/// - length of `action_id` greater than 255
/// - length of `options` less than 2 or greater than 5
/// - one or more of `options` is invalid (**TODO**)
/// - `confirm` is set and an invalid `Confirm`
///
/// # Example
/// ```
/// use slack_blocks::{block_elements::Overflow, compose::Opt};
///
/// fn repeat<T: Copy>(el: T, n: usize) -> impl Iterator<Item = T> {
/// std::iter::repeat(el).take(n)
/// }
///
/// let long_string: String = repeat('a', 256).collect();
///
/// let opt = Opt::builder().text_plain("foo")
/// .value("bar")
/// .no_url()
/// .build();
///
/// let opts: Vec<Opt<_, _>> = repeat(&opt, 6).map(|o| o.clone()).collect();
///
/// let input = Overflow::builder().action_id(long_string) // invalid
/// .options(opts) // also invalid
/// .build();
///
/// assert!(matches!(input.validate(), Err(_)))
/// ```
pub fn validate(&self) -> ValidationResult {
Validate::validate(self)
}
}

fn validate_options<'a>(options: &Cow<'a, [MyOpt<'a>]>) -> ValidatorResult {
len("Overflow.options", 2..=5, options.as_ref())
}

pub mod build {
use std::marker::PhantomData;

use super::*;
use crate::build::*;

#[allow(non_camel_case_types)]
pub mod method {
pub struct action_id;
pub struct options;
}

/// Initial state for overflow builder
pub type OverflowBuilderInit<'a> =
OverflowBuilder<'a,
RequiredMethodNotCalled<method::action_id>,
RequiredMethodNotCalled<method::options>>;

/// Overflow Menu Builder
///
/// Allows you to construct safely, with compile-time checks
/// on required setter methods.
///
/// # Required Methods
/// `OverflowBuilder::build()` is only available if these methods have been called:
/// - `action_id`
/// - `options`
///
/// # Example
/// ```
/// use slack_blocks::{block_elements::Overflow, compose::Opt};
///
/// Overflow::builder()
/// .action_id("foo")
/// .options(vec![
/// Opt::builder()
/// .text_plain("Open in browser")
/// .value("open_ext")
/// .url("https://foo.org")
/// .build(),
/// Opt::builder()
/// .text_plain("Do stuff")
/// .value("do_stuff")
/// .no_url()
/// .build(),
/// ]);
/// ```
pub struct OverflowBuilder<'a, A, O> {
action_id: Option<Cow<'a, str>>,
options: Option<Cow<'a, [MyOpt<'a>]>>,
confirm: Option<Confirm>,
state: PhantomData<(A, O)>,
}

impl<'a, A, O> OverflowBuilder<'a, A, O> {
/// Create a new empty builder
pub fn new() -> Self {
Self { action_id: None,
options: None,
confirm: None,
state: PhantomData::<_> }
}

/// Cast the internal static builder state to some other arbitrary state
fn cast_state<A2, O2>(self) -> OverflowBuilder<'a, A2, O2> {
OverflowBuilder { action_id: self.action_id,
options: self.options,
confirm: self.confirm,
state: PhantomData::<_> }
}

/// Set `action_id` (**Required**)
///
/// An identifier for the action triggered when a menu option is selected.
///
/// You can use this when you receive an interaction payload to [identify the source of the action 🔗].
///
/// Should be unique among all other `action_id`s in the containing block.
///
/// Maximum length for this field is 255 characters.
///
/// [identify the source of the action 🔗]: https://api.slack.com/interactivity/handling#payloads
pub fn action_id<T>(mut self,
action_id: T)
-> OverflowBuilder<'a, Set<method::action_id>, O>
where T: Into<Cow<'a, str>>
{
self.action_id = Some(action_id.into());
self.cast_state()
}

/// Set `options` (**Required**)
///
/// An array of [option objects 🔗] to display in the menu.
///
/// Maximum number of options is 5, minimum is 2.
///
/// [option objects 🔗]: https://api.slack.com/reference/block-kit/composition-objects#option
pub fn options<T>(mut self,
options: T)
-> OverflowBuilder<'a, A, Set<method::options>>
where T: Into<Cow<'a, [MyOpt<'a>]>>
{
self.options = Some(options.into());
self.cast_state()
}

/// Set `confirm` (Optional)
///
/// A [confirm object 🔗] that defines an optional confirmation dialog that appears after a menu item is selected.
///
/// [confirm object 🔗]: https://api.slack.com/reference/block-kit/composition-objects#confirm
pub fn confirm(mut self, confirm: Confirm) -> Self {
self.confirm = Some(confirm);
self
}
}

impl<'a> OverflowBuilder<'a, Set<method::action_id>, Set<method::options>> {
/// All done building, now give me a darn overflow menu!
///
/// > `no method name 'build' found for struct 'OverflowBuilder<...>'`?
/// Make sure all required setter methods have been called. See docs for `OverflowBuilder`.
///
/// ```compile_fail
/// use slack_blocks::block_elements::Overflow;
///
/// let foo = Overflow::builder().build(); // Won't compile!
/// ```
///
/// ```
/// use slack_blocks::{block_elements::Overflow, compose::Opt};
///
/// let foo = Overflow::builder().action_id("bar")
/// .options(vec![Opt::builder().text_plain("foo")
/// .value("bar")
/// .no_url()
/// .build()])
/// .build();
/// ```
pub fn build(self) -> Overflow<'a> {
Overflow { action_id: self.action_id.unwrap(),
options: self.options.unwrap(),
confirm: self.confirm }
}
}
}
10 changes: 5 additions & 5 deletions src/block_elements/radio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ use std::borrow::Cow;
use serde::{Deserialize as De, Serialize as Ser};
use validator::Validate;

use crate::{compose::{opt::{AnyText, UrlUnset},
use crate::{compose::{opt::{AnyText, NoUrl},
Confirm,
Opt},
text,
val_helpr::ValidationResult};

pub type MyOpt<'a> = Opt<'a, AnyText, UrlUnset>;
pub type MyOpt<'a> = Opt<'a, AnyText, NoUrl>;

/// # Radio Buttons
///
Expand Down Expand Up @@ -183,7 +183,7 @@ pub mod build {
self,
options: I)
-> RadioBuilder<'a, T2, A, Set<method::options>>
where I: IntoIterator<Item = Opt<'a, T2, UrlUnset>>
where I: IntoIterator<Item = Opt<'a, T2, NoUrl>>
{
let options = options.into_iter().map(|o| o.into()).collect();

Expand Down Expand Up @@ -215,7 +215,7 @@ pub mod build {
///
/// [option object 🔗]: https://api.slack.com/reference/messaging/composition-objects#option
pub fn initial_option(mut self,
option: Opt<'a, text::Plain, UrlUnset>)
option: Opt<'a, text::Plain, NoUrl>)
-> Self {
self.initial_option = Some(option.into());
self
Expand All @@ -231,7 +231,7 @@ pub mod build {
///
/// [option object 🔗]: https://api.slack.com/reference/messaging/composition-objects#option
pub fn initial_option(mut self,
option: Opt<'a, text::Mrkdwn, UrlUnset>)
option: Opt<'a, text::Mrkdwn, NoUrl>)
-> Self {
self.initial_option = Some(option.into());
self
Expand Down
6 changes: 3 additions & 3 deletions src/block_elements/select/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use validator::Validate;

use crate::{compose::{opt::UrlUnset, Confirm, OptOrOptGroup},
use crate::{compose::{opt::NoUrl, Confirm, OptOrOptGroup},
text,
val_helpr::ValidationResult};

Expand All @@ -24,7 +24,7 @@ pub struct External<'a> {
#[validate(length(max = 255))]
action_id: Cow<'a, str>,

initial_option: Option<OptOrOptGroup<'a, text::Plain, UrlUnset>>,
initial_option: Option<OptOrOptGroup<'a, text::Plain, NoUrl>>,

min_query_length: Option<u64>,

Expand Down Expand Up @@ -209,7 +209,7 @@ impl<'a> External<'a> {
pub fn with_initial_option(mut self,
option: impl Into<OptOrOptGroup<'a,
text::Plain,
UrlUnset>>)
NoUrl>>)
-> Self {
self.initial_option = Some(option.into());
self
Expand Down
Loading