stepflow_action/action/
action_htmlform.rs

1use std::{collections::HashMap, fmt::Write};
2use stepflow_base::{ObjectStoreFiltered, IdError};
3use stepflow_data::{StateDataFiltered, var::{Var, VarId, StringVar, EmailVar, BoolVar}, value::StringValue};
4use super::{ActionResult, Action, ActionId, Step, ActionError};
5use crate::{render_template, EscapedString, HtmlEscapedString};
6
7
8/// Configuration for [`HtmlFormAction`]
9///
10/// Customize the output of [`HtmlFormAction`] with these parameters. The templates can use `{{name}}` as a placeholder for the [`Var`] name.
11///
12/// ```
13/// # use stepflow_action::HtmlFormConfig;
14/// let mut html_form_config: HtmlFormConfig = Default::default();
15/// html_form_config.stringvar_html_template = "<textarea name='{{name}}'></textarea>".to_owned();
16/// ```
17// Someday we should have a HtmlFormTag trait that any var can implement and then call that for their tag. not able until we can cast a Var trait to a HtmlFormTag trait
18#[derive(Debug)]
19pub struct HtmlFormConfig {
20  /// HTML template for [`StringVar`] 
21  pub stringvar_html_template: String,
22
23  /// HTML template for [`EmailVar`] 
24  pub emailvar_html_template: String,
25
26  /// HTML template for [`BoolVar`] 
27  pub boolvar_html_template: String,
28
29  /// Optional HTML template inserted before any field
30  /// For example, you can output a label for every field with:
31  /// ```
32  /// # use stepflow_action::HtmlFormConfig;
33  /// # let mut html_form_config: HtmlFormConfig = Default::default();
34  /// html_form_config.prefix_html_template = Some("<label for='{{name}}'>{{name}}</label>".to_owned());
35  /// ```
36  pub prefix_html_template: Option<String>,
37
38  /// HTML tag that will wrap the prefix and field templates.
39  /// For example, you can wrap every field + label with a div:
40  /// ```
41  /// # use stepflow_action::HtmlFormConfig;
42  /// # let mut html_form_config: HtmlFormConfig = Default::default();
43  /// html_form_config.wrap_tag = Some("div".to_owned());
44  /// ```
45
46  pub wrap_tag: Option<String>, // ie. wrap entire element in a <div></div>
47}
48
49impl HtmlFormConfig {
50  fn format_html_template(tag_template: &HtmlEscapedString, name_escaped: &HtmlEscapedString) -> String {
51    let mut params = HashMap::new();
52    params.insert("name", name_escaped);
53    render_template::<&HtmlEscapedString>(&tag_template, params)
54  }
55
56  fn valid_wraptag(&self) -> Option<&String> {
57    if let Some(wrap_tag) = &self.wrap_tag {
58      if !wrap_tag.is_empty() {
59        return Some(wrap_tag);
60      }
61    }
62    None
63  }
64
65  fn format_input_template(&self, html_template: &String, name_escaped: &HtmlEscapedString) -> Result<String, std::fmt::Error> {
66    let mut html = String::with_capacity(html_template.len() + name_escaped.len()); // rough guss
67
68    // write the head of the wrap
69    if let Some(wrap_tag) = self.valid_wraptag() {
70      if !wrap_tag.is_empty() {
71        write!(html, "<{}>", wrap_tag)?;
72      }
73    }
74
75    // write the prefix
76    if let Some(prefix_html_template) = &self.prefix_html_template {
77      let prefix_html = Self::format_html_template(&HtmlEscapedString::already_escaped(prefix_html_template.to_owned()), name_escaped);
78      html.write_str(&prefix_html[..])?;
79    }
80
81    // write the tag
82    let input_html = Self::format_html_template(&HtmlEscapedString::already_escaped(html_template.to_owned()), name_escaped);
83    html.write_str(&input_html[..])?;
84
85    // write the tail of the wrap
86    if let Some(wrap_tag) = self.valid_wraptag() {
87      write!(html, "</{}>", wrap_tag)?;
88    }
89  
90
91    Ok(html)
92  }
93}
94
95impl Default for HtmlFormConfig {
96    fn default() -> Self {
97        HtmlFormConfig {
98          stringvar_html_template: "<input name='{{name}}' type='text' />".to_owned(),
99          emailvar_html_template: "<input name='{{name}}' type='email' />".to_owned(),
100          boolvar_html_template: "<input name='{{name}}' type='checkbox' />".to_owned(),
101          prefix_html_template: None,
102          wrap_tag: None,
103        }
104    }
105}
106
107
108/// Action to generate an HTML form for a [`Step`]
109///
110/// The action looks iterates through all the outputs of the current Step and generates HTML based on the [`HtmlFormConfig`].
111/// The HTML is returned as a string in the [`ActionResult::StartWith`] result
112#[derive(Debug)]
113pub struct HtmlFormAction {
114  id: ActionId,
115  html_config: HtmlFormConfig,
116}
117
118impl HtmlFormAction {
119  /// Create a new HtmlFormAction
120  pub fn new(id: ActionId, html_config: HtmlFormConfig) -> Self {
121    HtmlFormAction {
122      id,
123      html_config,
124    }
125  }
126
127  pub fn boxed(self) -> Box<dyn Action + Sync + Send> {
128    Box::new(self)
129  }
130}
131
132impl Action for HtmlFormAction {
133  fn id(&self) -> &ActionId {
134    &self.id
135  }
136
137  fn start(&mut self, step: &Step, _step_name: Option<&str>, _step_data: &StateDataFiltered, vars: &ObjectStoreFiltered<Box<dyn Var + Send + Sync>, VarId>)
138    -> Result<ActionResult, ActionError>
139  {
140    const AVG_NAME_LEN: usize = 5;
141    let mut html = String::with_capacity(step.get_output_vars().len() * (self.html_config.stringvar_html_template.len() + AVG_NAME_LEN));
142    for var_id in step.get_output_vars().iter() {
143      let name = vars.name_from_id(var_id).ok_or_else(|| ActionError::VarId(IdError::IdHasNoName(var_id.clone())))?;
144      let name_escaped = HtmlEscapedString::from_unescaped(&(name.to_string())[..]);
145
146      let var = vars.get(var_id).ok_or_else(|| ActionError::VarId(IdError::IdMissing(var_id.clone())))?;
147      let html_template;
148      if var.is::<StringVar>() {
149        html_template = &self.html_config.stringvar_html_template;
150      } else if var.is::<EmailVar>() {
151        html_template = &self.html_config.emailvar_html_template;
152      } else if var.is::<BoolVar>() {
153        html_template = &self.html_config.boolvar_html_template;
154      } else {
155        // perhaps panic when in debug? 
156        // maybe in the future we should ask variables to support a trait that gets their HTML format
157        return Err(ActionError::VarId(IdError::IdUnexpected(var_id.clone())));
158      }
159
160      self.html_config
161        .format_input_template(html_template, &name_escaped)
162        .and_then(|input_html| html.write_str(&input_html[..]))
163        .map_err(|_e| ActionError::Other)?;
164    }
165
166    let stringval = StringValue::try_new(html).map_err(|_e| ActionError::Other)?;
167    Ok(ActionResult::StartWith(stringval.boxed()))
168  }
169}
170
171
172
173#[cfg(test)]
174mod tests {
175  use std::collections::HashSet;
176  use super::{HtmlEscapedString, EscapedString, HtmlFormConfig, HtmlFormAction};
177  use stepflow_base::{ObjectStore, ObjectStoreFiltered};
178  use stepflow_data::{StateData, StateDataFiltered, var::{Var, VarId, EmailVar, StringVar}, value::StringValue};
179  use stepflow_step::{Step, StepId};
180  use stepflow_test_util::test_id;
181  use super::super::{ActionResult, Action, ActionId};
182
183  #[test]
184  fn html_format_input() {
185    let mut html_config: HtmlFormConfig = Default::default();
186    html_config.stringvar_html_template = "s({{name}},{{name}})".to_owned();
187    html_config.emailvar_html_template = "e({{name}},{{name}})".to_owned();
188
189    // simple case
190    let escaped_n = HtmlEscapedString::from_unescaped("n");
191    let formatted = html_config.format_input_template(&html_config.stringvar_html_template, &escaped_n).unwrap();
192    assert_eq!(formatted, "s(n,n)");
193
194    // add prefix
195    html_config.prefix_html_template = Some("p({{name}})".to_owned());
196    let formatted_prefix = html_config.format_input_template(&html_config.stringvar_html_template, &escaped_n).unwrap();
197    assert_eq!(formatted_prefix, "p(n)s(n,n)");
198
199    // add wrap
200    html_config.wrap_tag = Some("div".to_owned());
201    let wrapped_prefix = html_config.format_input_template(&html_config.stringvar_html_template, &escaped_n).unwrap();
202    assert_eq!(wrapped_prefix, "<div>p(n)s(n,n)</div>");
203
204    // empty wrap
205    html_config.wrap_tag = Some(String::new());
206    let wrapped_empty = html_config.format_input_template(&html_config.stringvar_html_template, &escaped_n).unwrap();
207    assert_eq!(wrapped_empty, "p(n)s(n,n)");
208  }
209
210  #[test]
211  fn simple_form() {
212    let var1 = StringVar::new(test_id!(VarId));
213    let var2 = EmailVar::new(test_id!(VarId));
214    let var_ids = vec![var1.id().clone(), var2.id().clone()];
215    let step = Step::new(StepId::new(4), None, var_ids.clone());
216
217    let state_data = StateData::new();
218    let var_filter = var_ids.iter().map(|id| id.clone()).collect::<HashSet<_>>();
219    let step_data_filtered = StateDataFiltered::new(&state_data, var_filter.clone());
220
221    let mut var_store: ObjectStore<Box<dyn Var + Send + Sync>, VarId> = ObjectStore::new();
222    var_store.register_named("var 1", var1.boxed()).unwrap();
223    var_store.register_named("var 2", var2.boxed()).unwrap();
224
225    let var_store_filtered = ObjectStoreFiltered::new(&var_store, var_filter);
226
227    let mut exec = HtmlFormAction::new(test_id!(ActionId), Default::default());
228    let action_result = exec.start(&step, None, &step_data_filtered, &var_store_filtered).unwrap();
229    if let ActionResult::StartWith(html) = action_result {
230      let html = html.downcast::<StringValue>().unwrap().val();
231      assert_eq!(html, "<input name='var&#x20;1' type='text' /><input name='var&#x20;2' type='email' />");
232    } else {
233      panic!("Did not get startwith value");
234    }
235
236    // customize the tags
237    let mut html_config: HtmlFormConfig = Default::default();
238    html_config.prefix_html_template = Some("p({{name}})".to_owned());
239    html_config.stringvar_html_template = "l({{name}})s({{name}})".to_owned();
240    html_config.emailvar_html_template = "l({{name}})e({{name}})".to_owned();
241    let mut custom_exec = HtmlFormAction::new(test_id!(ActionId), html_config);
242    let custom_result = custom_exec.start(&step, None, &step_data_filtered, &var_store_filtered).unwrap();
243    if let ActionResult::StartWith(html) = custom_result {
244      let html = html.downcast::<StringValue>().unwrap().val();
245      assert_eq!(html, "p(var&#x20;1)l(var&#x20;1)s(var&#x20;1)p(var&#x20;2)l(var&#x20;2)e(var&#x20;2)");
246    } else {
247      panic!("Did not get startwith value");
248    }
249  }
250
251}