Skip to main content

bob_chat/
modal.rs

1//! Modal element types and builders for interactive form dialogs.
2//!
3//! Modals are form dialogs containing text inputs, selects, and radio selects.
4//! They are opened by `ChatAdapter::open_modal` and processed by modal event
5//! handlers. Platform adapters render them to their native format (e.g. Slack
6//! views, Discord modals).
7
8use serde::{Deserialize, Serialize};
9
10// ---------------------------------------------------------------------------
11// Core types
12// ---------------------------------------------------------------------------
13
14/// A modal dialog composed of form input elements.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ModalElement {
17    /// Unique identifier used to route modal submission callbacks.
18    pub callback_id: String,
19    /// Title displayed at the top of the modal.
20    pub title: String,
21    /// Optional label for the submit button (defaults to platform default).
22    pub submit_label: Option<String>,
23    /// Ordered list of child form elements.
24    pub children: Vec<ModalChild>,
25    /// Optional opaque metadata passed through submission callbacks.
26    pub private_metadata: Option<String>,
27    /// When `true`, the adapter should fire a close/cancel event.
28    pub notify_on_close: bool,
29}
30
31/// A child element inside a [`ModalElement`].
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub enum ModalChild {
34    /// A text input field.
35    TextInput(TextInputElement),
36    /// A dropdown select menu.
37    Select(SelectElement),
38    /// A radio-button select group.
39    RadioSelect(RadioSelectElement),
40}
41
42/// A single-line or multi-line text input element.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TextInputElement {
45    /// Unique identifier for the input (used in submission payloads).
46    pub id: String,
47    /// Label displayed above the input.
48    pub label: String,
49    /// Placeholder text shown when the input is empty.
50    pub placeholder: Option<String>,
51    /// Pre-filled value.
52    pub initial_value: Option<String>,
53    /// When `true`, renders as a multi-line text area.
54    pub multiline: bool,
55    /// When `true`, the field may be left blank on submission.
56    pub optional: bool,
57}
58
59/// A dropdown select element.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SelectElement {
62    /// Unique identifier for the select (used in submission payloads).
63    pub id: String,
64    /// Label displayed above the select.
65    pub label: String,
66    /// Placeholder text shown when no option is selected.
67    pub placeholder: Option<String>,
68    /// Available options.
69    pub options: Vec<SelectOption>,
70    /// Value of the initially selected option.
71    pub initial_option: Option<String>,
72}
73
74/// A single option inside a [`SelectElement`] or [`RadioSelectElement`].
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SelectOption {
77    /// Display label for the option.
78    pub label: String,
79    /// Machine-readable value for the option.
80    pub value: String,
81}
82
83/// A radio-button select group.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RadioSelectElement {
86    /// Unique identifier for the radio group (used in submission payloads).
87    pub id: String,
88    /// Label displayed above the radio group.
89    pub label: String,
90    /// Available options.
91    pub options: Vec<SelectOption>,
92    /// Value of the initially selected option.
93    pub initial_option: Option<String>,
94}
95
96// ---------------------------------------------------------------------------
97// Builders – ModalElement
98// ---------------------------------------------------------------------------
99
100impl ModalElement {
101    /// Create a new modal with the given callback identifier and title.
102    #[must_use]
103    pub fn new(callback_id: impl Into<String>, title: impl Into<String>) -> Self {
104        Self {
105            callback_id: callback_id.into(),
106            title: title.into(),
107            submit_label: None,
108            children: Vec::new(),
109            private_metadata: None,
110            notify_on_close: false,
111        }
112    }
113
114    /// Set the submit button label.
115    #[must_use]
116    pub fn submit_label(mut self, label: impl Into<String>) -> Self {
117        self.submit_label = Some(label.into());
118        self
119    }
120
121    /// Append a text input child element.
122    #[must_use]
123    pub fn text_input(mut self, input: TextInputElement) -> Self {
124        self.children.push(ModalChild::TextInput(input));
125        self
126    }
127
128    /// Append a select child element.
129    #[must_use]
130    pub fn select(mut self, select: SelectElement) -> Self {
131        self.children.push(ModalChild::Select(select));
132        self
133    }
134
135    /// Append a radio select child element.
136    #[must_use]
137    pub fn radio_select(mut self, radio: RadioSelectElement) -> Self {
138        self.children.push(ModalChild::RadioSelect(radio));
139        self
140    }
141
142    /// Set opaque metadata passed through submission callbacks.
143    #[must_use]
144    pub fn private_metadata(mut self, metadata: impl Into<String>) -> Self {
145        self.private_metadata = Some(metadata.into());
146        self
147    }
148
149    /// Set whether a close/cancel event should be fired.
150    #[must_use]
151    pub fn notify_on_close(mut self, notify: bool) -> Self {
152        self.notify_on_close = notify;
153        self
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Builders – TextInputElement
159// ---------------------------------------------------------------------------
160
161impl TextInputElement {
162    /// Create a new text input with the given identifier and label.
163    #[must_use]
164    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
165        Self {
166            id: id.into(),
167            label: label.into(),
168            placeholder: None,
169            initial_value: None,
170            multiline: false,
171            optional: false,
172        }
173    }
174
175    /// Set placeholder text.
176    #[must_use]
177    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
178        self.placeholder = Some(placeholder.into());
179        self
180    }
181
182    /// Set the initial (pre-filled) value.
183    #[must_use]
184    pub fn initial_value(mut self, value: impl Into<String>) -> Self {
185        self.initial_value = Some(value.into());
186        self
187    }
188
189    /// Set whether the input renders as a multi-line text area.
190    #[must_use]
191    pub fn multiline(mut self, multiline: bool) -> Self {
192        self.multiline = multiline;
193        self
194    }
195
196    /// Set whether the field is optional.
197    #[must_use]
198    pub fn optional(mut self, optional: bool) -> Self {
199        self.optional = optional;
200        self
201    }
202}
203
204// ---------------------------------------------------------------------------
205// Builders – SelectElement
206// ---------------------------------------------------------------------------
207
208impl SelectElement {
209    /// Create a new select with the given identifier and label.
210    #[must_use]
211    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
212        Self {
213            id: id.into(),
214            label: label.into(),
215            placeholder: None,
216            options: Vec::new(),
217            initial_option: None,
218        }
219    }
220
221    /// Set placeholder text.
222    #[must_use]
223    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
224        self.placeholder = Some(placeholder.into());
225        self
226    }
227
228    /// Append an option.
229    #[must_use]
230    pub fn option(mut self, option: SelectOption) -> Self {
231        self.options.push(option);
232        self
233    }
234
235    /// Set the initially selected option by value.
236    #[must_use]
237    pub fn initial_option(mut self, value: impl Into<String>) -> Self {
238        self.initial_option = Some(value.into());
239        self
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Builders – SelectOption
245// ---------------------------------------------------------------------------
246
247impl SelectOption {
248    /// Create a new option with the given label and value.
249    #[must_use]
250    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
251        Self { label: label.into(), value: value.into() }
252    }
253}
254
255// ---------------------------------------------------------------------------
256// Builders – RadioSelectElement
257// ---------------------------------------------------------------------------
258
259impl RadioSelectElement {
260    /// Create a new radio select group with the given identifier and label.
261    #[must_use]
262    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
263        Self { id: id.into(), label: label.into(), options: Vec::new(), initial_option: None }
264    }
265
266    /// Append an option.
267    #[must_use]
268    pub fn option(mut self, option: SelectOption) -> Self {
269        self.options.push(option);
270        self
271    }
272
273    /// Set the initially selected option by value.
274    #[must_use]
275    pub fn initial_option(mut self, value: impl Into<String>) -> Self {
276        self.initial_option = Some(value.into());
277        self
278    }
279}
280
281// ---------------------------------------------------------------------------
282// Tests
283// ---------------------------------------------------------------------------
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn empty_modal_construction() {
291        let modal = ModalElement::new("cb_1", "My Modal");
292        assert_eq!(modal.callback_id, "cb_1");
293        assert_eq!(modal.title, "My Modal");
294        assert!(modal.submit_label.is_none());
295        assert!(modal.children.is_empty());
296        assert!(modal.private_metadata.is_none());
297        assert!(!modal.notify_on_close);
298    }
299
300    #[test]
301    fn builder_chaining() {
302        let modal = ModalElement::new("cb_settings", "Settings")
303            .submit_label("Save")
304            .private_metadata("{\"v\":1}")
305            .notify_on_close(true)
306            .text_input(
307                TextInputElement::new("name", "Your Name")
308                    .placeholder("Enter name")
309                    .initial_value("Alice")
310                    .multiline(false)
311                    .optional(false),
312            )
313            .select(
314                SelectElement::new("color", "Favourite Colour")
315                    .placeholder("Pick one")
316                    .option(SelectOption::new("Red", "red"))
317                    .option(SelectOption::new("Blue", "blue"))
318                    .initial_option("blue"),
319            )
320            .radio_select(
321                RadioSelectElement::new("size", "T-Shirt Size")
322                    .option(SelectOption::new("Small", "s"))
323                    .option(SelectOption::new("Medium", "m"))
324                    .option(SelectOption::new("Large", "l"))
325                    .initial_option("m"),
326            );
327
328        assert_eq!(modal.submit_label.as_deref(), Some("Save"));
329        assert_eq!(modal.private_metadata.as_deref(), Some("{\"v\":1}"));
330        assert!(modal.notify_on_close);
331        assert_eq!(modal.children.len(), 3);
332
333        // Verify child types
334        assert!(matches!(modal.children[0], ModalChild::TextInput(_)));
335        assert!(matches!(modal.children[1], ModalChild::Select(_)));
336        assert!(matches!(modal.children[2], ModalChild::RadioSelect(_)));
337
338        // Verify text input details
339        if let ModalChild::TextInput(ref input) = modal.children[0] {
340            assert_eq!(input.id, "name");
341            assert_eq!(input.label, "Your Name");
342            assert_eq!(input.placeholder.as_deref(), Some("Enter name"));
343            assert_eq!(input.initial_value.as_deref(), Some("Alice"));
344            assert!(!input.multiline);
345            assert!(!input.optional);
346        }
347
348        // Verify select details
349        if let ModalChild::Select(ref select) = modal.children[1] {
350            assert_eq!(select.id, "color");
351            assert_eq!(select.options.len(), 2);
352            assert_eq!(select.options[0].label, "Red");
353            assert_eq!(select.options[0].value, "red");
354            assert_eq!(select.initial_option.as_deref(), Some("blue"));
355        }
356
357        // Verify radio select details
358        if let ModalChild::RadioSelect(ref radio) = modal.children[2] {
359            assert_eq!(radio.id, "size");
360            assert_eq!(radio.options.len(), 3);
361            assert_eq!(radio.initial_option.as_deref(), Some("m"));
362        }
363    }
364
365    #[test]
366    fn serde_roundtrip() {
367        let original = ModalElement::new("cb_rt", "Roundtrip")
368            .submit_label("Go")
369            .notify_on_close(true)
370            .text_input(
371                TextInputElement::new("field1", "Field 1")
372                    .placeholder("type here")
373                    .multiline(true)
374                    .optional(true),
375            )
376            .select(
377                SelectElement::new("sel1", "Select 1")
378                    .option(SelectOption::new("A", "a"))
379                    .initial_option("a"),
380            )
381            .radio_select(
382                RadioSelectElement::new("rad1", "Radio 1")
383                    .option(SelectOption::new("X", "x"))
384                    .option(SelectOption::new("Y", "y")),
385            );
386
387        let json = serde_json::to_string(&original).expect("serialize");
388        let restored: ModalElement = serde_json::from_str(&json).expect("deserialize");
389
390        assert_eq!(restored.callback_id, original.callback_id);
391        assert_eq!(restored.title, original.title);
392        assert_eq!(restored.submit_label, original.submit_label);
393        assert_eq!(restored.notify_on_close, original.notify_on_close);
394        assert_eq!(restored.children.len(), original.children.len());
395
396        // Deep check on text input child
397        if let (ModalChild::TextInput(orig), ModalChild::TextInput(rest)) =
398            (&original.children[0], &restored.children[0])
399        {
400            assert_eq!(orig.id, rest.id);
401            assert_eq!(orig.label, rest.label);
402            assert_eq!(orig.placeholder, rest.placeholder);
403            assert_eq!(orig.initial_value, rest.initial_value);
404            assert_eq!(orig.multiline, rest.multiline);
405            assert_eq!(orig.optional, rest.optional);
406        } else {
407            panic!("expected TextInput children");
408        }
409    }
410
411    #[test]
412    fn serde_roundtrip_empty_modal() {
413        let original = ModalElement::new("cb_empty", "Empty");
414        let json = serde_json::to_string(&original).expect("serialize");
415        let restored: ModalElement = serde_json::from_str(&json).expect("deserialize");
416
417        assert_eq!(restored.callback_id, "cb_empty");
418        assert_eq!(restored.title, "Empty");
419        assert!(restored.children.is_empty());
420        assert!(!restored.notify_on_close);
421    }
422}