vertigo_forms/form/
mod.rs

1//! This module allows to quickly create a form based on provided model.
2//!
3//! The model needs to implement converting to [FormData] and from [FormExport],
4//! then it can be passed directly to [Form] component.
5//!
6//! See story book for examples.
7
8use std::rc::Rc;
9use vertigo::{AttrGroup, Computed, Css, Value, bind, bind_rc, component, css, dom};
10
11use crate::{TabsParams, ValidationErrors};
12
13mod data;
14pub use data::*;
15
16mod render;
17pub use render::*;
18
19#[derive(Clone)]
20pub struct FormParams<T: 'static> {
21    pub css: Css,
22    pub add_css: Css,
23    pub add_section_css: Css,
24    pub submit_label: Rc<String>,
25    pub on_delete: Option<Rc<dyn Fn()>>,
26    pub delete_label: Rc<String>,
27    pub validate: Option<ValidateFunc<T>>,
28    pub validation_errors: Value<ValidationErrors>,
29    pub operation: Option<Value<Operation>>,
30    pub saving_label: Rc<String>,
31    pub saved_label: Rc<String>,
32    pub tabs_params: Option<TabsParams>,
33}
34
35impl<T: 'static> Default for FormParams<T> {
36    fn default() -> Self {
37        Self {
38            css: css! { "
39                display: grid;
40                grid-template-rows: auto 1fr;
41                gap: 5px;
42            " },
43            add_css: Css::default(),
44            add_section_css: Css::default(),
45            submit_label: Rc::new("Submit".to_string()),
46            on_delete: None,
47            delete_label: Rc::new("Delete".to_string()),
48            validate: None,
49            validation_errors: Default::default(),
50            operation: Default::default(),
51            saving_label: Rc::new("Saving...".to_string()),
52            saved_label: Rc::new("Saved".to_string()),
53            tabs_params: None,
54        }
55    }
56}
57
58/// Renders a form for provided model, that upon "Save" allows to update a model with new values.
59///
60/// A model needs to implement conversion to [FormData] and from [FormExport] to interoperate with this component.
61///
62/// See [FormData] for description how to manage form structure.
63///
64/// Use `f` attribute group to pass anything to underlying <form> element (ex. `f:css="my_styles"`)
65#[component]
66pub fn ModelForm<T: Clone + PartialEq>(
67    model: Computed<T>,
68    on_submit: Rc<dyn Fn(T)>,
69    params: FormParams<T>,
70    f: AttrGroup,
71    s: AttrGroup,
72) where
73    FormData: From<T>,
74    T: From<FormExport> + 'static,
75{
76    model.render_value(move |model| {
77        let form_data = Rc::new(FormData::from(model));
78
79        let on_submit = bind_rc!(on_submit, form_data, |form_export: FormExport| {
80            on_submit(T::from(form_export));
81        });
82
83        let mut form_component = Form {
84            form_data,
85            on_submit,
86            params: params.clone(),
87        }
88        .into_component();
89
90        form_component.f = f.clone();
91        form_component.s = s.clone();
92
93        form_component.mount()
94    })
95}
96
97/// Renders a form for provided [FormData] that upon "Save" allows to grab updated fields from [FormExport].
98///
99/// See [FormData] for description how to manage form structure.
100///
101/// Use `f` attribute group to pass anything to underlying <form> element (ex. `f:css="my_styles"`)
102/// Use `s` attribute group to pass anything to underlying section (<label> element) (ex. `s:css="my_styles"`)
103#[component]
104pub fn Form<T>(
105    form_data: Rc<FormData>,
106    on_submit: Rc<dyn Fn(FormExport)>,
107    params: FormParams<T>,
108    // form attrs
109    f: AttrGroup,
110    // section attrs
111    s: AttrGroup,
112) where
113    T: From<FormExport> + 'static,
114{
115    let subgrid_css = css! {"
116        display: grid;
117        grid-template-columns: subgrid;
118        grid-column: span 2 / span 2;
119    "};
120
121    let validation_errors = params.validation_errors.clone();
122
123    let controls = |params: &FormParams<T>, c_config: &ControlsConfig| {
124        let mut controls = vec![];
125
126        let ctrl_item_css = css! {"
127            margin: 5px;
128        "};
129
130        if c_config.submit {
131            controls.push(dom! {
132                <input css={&ctrl_item_css} type="submit" value={&params.submit_label} />
133            });
134        }
135        if c_config.delete
136            && let Some(on_click) = params.on_delete.clone()
137        {
138            controls.push(dom! {
139                <input css={&ctrl_item_css} type="submit" value={&params.delete_label} on_click={move |_| on_click()} />
140            });
141        }
142
143        let errors = validation_errors
144            .render_value_option(|errs| errs.get("submit").map(|err| dom! { <span>{err}</span> }));
145
146        let operation_str = params.operation.as_ref().map(|operation| {
147            bind!(
148                params.saving_label,
149                params.saved_label,
150                operation.render_value_option(move |oper| {
151                    let mut css = ctrl_item_css.clone();
152                    match oper {
153                        Operation::Saving => Some(saving_label.clone()),
154                        Operation::Success => Some(saved_label.clone()),
155                        Operation::Error(err) => {
156                            css += css! {"color: red;"};
157                            Some(err)
158                        }
159                        _ => None,
160                    }
161                    .map(|operation_str| dom! { <span {css}>{operation_str}</span> })
162                })
163            )
164        });
165
166        if controls.is_empty() {
167            None
168        } else {
169            let mut css_controls = css!("grid-column: span 2;");
170            if let Some(custom_css) = &c_config.css {
171                css_controls += custom_css;
172            }
173            Some(dom! {
174                <div css={css_controls}>
175                    {..controls}
176                    {errors}
177                    {..operation_str}
178                </div>
179            })
180        }
181    };
182
183    let top_controls = controls(&params, &form_data.top_controls);
184    let bottom_controls = controls(&params, &form_data.bottom_controls);
185
186    let section_css = subgrid_css + params.add_section_css;
187
188    let fields = fields(
189        &form_data.sections,
190        &s,
191        validation_errors.clone(),
192        &section_css,
193    );
194
195    let tabs = tabs(
196        &form_data.tabs,
197        &params.tabs_params,
198        &s,
199        validation_errors.clone(),
200        &section_css,
201        &params.css.clone(),
202    );
203
204    let form_css = params.css + params.add_css;
205
206    let on_submit = bind_rc!(form_data, validation_errors, || {
207        params
208            .operation
209            .as_ref()
210            .inspect(|operation| operation.set(Operation::Saving));
211        let model = form_data.export();
212        let valid = if let Some(validate) = &params.validate {
213            validate(&model.clone().into(), validation_errors.clone())
214        } else {
215            true
216        };
217        if valid {
218            on_submit(model);
219        }
220    });
221
222    dom! {
223        <form css={form_css} on_submit={on_submit} {..f}>
224            {..top_controls}
225            {..fields}
226            {..tabs}
227            {..bottom_controls}
228        </form>
229    }
230}