From 647f1df5ce469081bf2618a0330e419e822caf66 Mon Sep 17 00:00:00 2001 From: Michal Pokrywka Date: Thu, 29 May 2025 20:14:58 +0200 Subject: [PATCH 1/4] login input css --- src/login.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/login.rs b/src/login.rs index f6a8a71..1a77d8f 100644 --- a/src/login.rs +++ b/src/login.rs @@ -14,6 +14,7 @@ pub struct LoginParams { pub add_css: Css, pub line_css: Css, pub line_add_css: Css, + pub input_css: Css, pub submit_css: Css, pub submit_add_css: Css, pub error_message: Rc String>, @@ -38,6 +39,7 @@ impl Default for LoginParams { margin-bottom: 5px; "}, line_add_css: Css::default(), + input_css: Css::default(), submit_css: css! {" margin-top: 15px; "}, @@ -111,7 +113,11 @@ impl Login { let username_div = dom! {
{¶ms.username_label}
- +
}; @@ -120,7 +126,12 @@ impl Login { let password_div = dom! {
{¶ms.password_label}
- +
}; From d99bfb5d7d2bbfd89ffec657024793bd4e00f721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pokrywka?= Date: Thu, 5 Jun 2025 16:22:11 +0200 Subject: [PATCH 2/4] AttrGroups for Inputs and DictSelect --- src/form/mod.rs | 4 +- src/input/input.rs | 49 +++++------------------ src/input/input_with_button.rs | 73 +++++++++++++++------------------- src/input/list_input.rs | 53 ++++++++++-------------- src/select/dict_select.rs | 64 +++++++++++++---------------- storybook/src/input.rs | 2 +- 6 files changed, 91 insertions(+), 154 deletions(-) diff --git a/src/form/mod.rs b/src/form/mod.rs index 68b4885..e2a0296 100644 --- a/src/form/mod.rs +++ b/src/form/mod.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use vertigo::{bind_rc, component, css, dom, Css}; -use crate::{input::NamedInput, DictSelect, DropImageFile, DropImageFileParams, Select}; +use crate::{input::Input, DictSelect, DropImageFile, DropImageFileParams, Select}; mod field; pub use field::{DataFieldValue, FieldExport, FormExport}; @@ -39,7 +39,7 @@ impl Default for FormParams { pub fn Field<'a>(field: &'a DataField) { match &field.value { DataFieldValue::String(val) => { - dom! { } + dom! { } } DataFieldValue::List(val) => { dom! { - } +#[component] +pub fn Input(value: Value, input: AttrGroup) { + let on_input = bind!(value, |new_value: String| { + value.set(new_value); + }); + + dom! { + } } diff --git a/src/input/input_with_button.rs b/src/input/input_with_button.rs index 65ad3b6..4548e1c 100644 --- a/src/input/input_with_button.rs +++ b/src/input/input_with_button.rs @@ -1,4 +1,4 @@ -use vertigo::{bind, computed_tuple, dom, transaction, Css, DomNode, Value}; +use vertigo::{bind, component, computed_tuple, dom, transaction, AttrGroup, Value}; /// Input connected to provided `Value`. /// @@ -23,64 +23,53 @@ use vertigo::{bind, computed_tuple, dom, transaction, Css, DomNode, Value}; /// /// }; /// ``` -pub struct InputWithButton { - pub value: Value, - pub params: InputWithButtonParams, +#[component] +pub fn InputWithButton( + value: Value, + params: InputWithButtonParams, + input: AttrGroup, + button: AttrGroup, +) { + let temp_value = Value::>::default(); + let display_value = + computed_tuple!(value, temp_value).map(|(value, temp_value)| temp_value.unwrap_or(value)); + + let on_input = bind!(temp_value, |new_value: String| { + temp_value.set(Some(new_value)); + }); + + let on_click = bind!(value, temp_value, |_| { + transaction(|ctx| { + let new_value = temp_value.get(ctx); + if let Some(new_value) = new_value { + value.set(new_value); + } + temp_value.set(None); + }) + }); + + dom! { + + + } } #[derive(Clone)] pub struct InputWithButtonParams { - pub input_css: Css, pub button_label: String, - pub button_css: Css, } impl Default for InputWithButtonParams { fn default() -> Self { Self { - input_css: Css::default(), button_label: "OK".to_string(), - button_css: Css::default(), - } - } -} - -impl InputWithButton { - pub fn into_component(self) -> Self { - self - } - - pub fn mount(self) -> DomNode { - let Self { value, params } = self; - - let temp_value = Value::>::default(); - let display_value = computed_tuple!(value, temp_value) - .map(|(value, temp_value)| temp_value.unwrap_or(value)); - - let on_input = bind!(temp_value, |new_value: String| { - temp_value.set(Some(new_value)); - }); - - let on_click = bind!(value, temp_value, |_| { - transaction(|ctx| { - let new_value = temp_value.get(ctx); - if let Some(new_value) = new_value { - value.set(new_value); - } - temp_value.set(None); - }) - }); - - dom! { - - } } } diff --git a/src/input/list_input.rs b/src/input/list_input.rs index 5090217..edd07e8 100644 --- a/src/input/list_input.rs +++ b/src/input/list_input.rs @@ -1,40 +1,29 @@ -use vertigo::{bind, dom, DomNode, Value}; +use vertigo::{bind, component, dom, AttrGroup, Value}; /// Input connected to provided `Value>`. /// /// It parsed a comma-separated input string and sets value as a vector. -pub struct ListInput { - pub value: Value>, -} - -impl ListInput { - pub fn into_component(self) -> Self { - self - } - - pub fn mount(self) -> DomNode { - let Self { value } = self; - - let value_str = value.map(|v| v.join(",")); +#[component] +pub fn ListInput(value: Value>, input: AttrGroup) { + let value_str = value.map(|v| v.join(",")); - let on_input = bind!(value, |new_value: String| { - value.set( - new_value - .split(',') - .filter_map(|v| { - let v = v.trim(); - if v.is_empty() { - None - } else { - Some(v.to_string()) - } - }) - .collect(), - ); - }); + let on_input = bind!(value, |new_value: String| { + value.set( + new_value + .split(',') + .filter_map(|v| { + let v = v.trim(); + if v.is_empty() { + None + } else { + Some(v.to_string()) + } + }) + .collect(), + ); + }); - dom! { - - } + dom! { + } } diff --git a/src/select/dict_select.rs b/src/select/dict_select.rs index 392bcff..9e968de 100644 --- a/src/select/dict_select.rs +++ b/src/select/dict_select.rs @@ -1,4 +1,4 @@ -use vertigo::{bind, dom, Computed, DomNode, Value}; +use vertigo::{bind, component, dom, AttrGroup, Computed, Value}; /// Simple Select component based on map of `i64`->`T` values. /// @@ -23,44 +23,34 @@ use vertigo::{bind, dom, Computed, DomNode, Value}; /// /> /// }; /// ``` -pub struct DictSelect { - pub value: Value, - pub options: Computed>, -} - -impl DictSelect -where - T: Clone + From + PartialEq + ToString + 'static, -{ - pub fn into_component(self) -> Self { - self - } - - pub fn mount(&self) -> DomNode { - let Self { value, options } = self; - let on_change = bind!(value, |new_value: String| { - value.set(new_value.parse().unwrap_or_default()); - }); +#[component] +pub fn DictSelect + PartialEq + ToString + 'static>( + value: Value, + options: Computed>, + select: AttrGroup, +) { + let on_change = bind!(value, |new_value: String| { + value.set(new_value.parse().unwrap_or_default()); + }); - let list = bind!( - options, - value.render_value(move |value| options.render_list( - |(key, _)| key.to_string(), - move |(key, item)| { - let text_item = item.to_string(); - if key == &value { - dom! { } - } else { - dom! { } - } + let list = bind!( + options, + value.render_value(move |value| options.render_list( + |(key, _)| key.to_string(), + move |(key, item)| { + let text_item = item.to_string(); + if key == &value { + dom! { } + } else { + dom! { } } - )) - ); + } + )) + ); - dom! { - - } + dom! { + } } diff --git a/storybook/src/input.rs b/storybook/src/input.rs index 33ba93f..71df725 100644 --- a/storybook/src/input.rs +++ b/storybook/src/input.rs @@ -33,7 +33,7 @@ pub fn input() -> DomNode { let list_input = dom! {

"ListInput"

- "Enter value: " + "Enter comma-separated values: "

From 06e2aebabb3015962cb1cc327f28a89f35bafc11 Mon Sep 17 00:00:00 2001 From: Michal Pokrywka Date: Fri, 25 Jul 2025 16:38:44 +0200 Subject: [PATCH 3/4] More options for Form --- Cargo.toml | 2 +- src/drop_image_file.rs | 18 ++- src/form/field.rs | 128 +++++++++++++++++--- src/form/form_data.rs | 106 +++++++++++++++-- src/form/mod.rs | 265 +++++++++++++++++++++++++++++++++++------ src/switch.rs | 92 +++++++------- storybook/src/form.rs | 39 +++--- 7 files changed, 517 insertions(+), 133 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7a3cf17..5ca0b83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["storybook", "examples/form"] name = "vertigo-forms" version = "0.1.0" authors = ["MichaƂ Pokrywka "] -edition = "2021" +edition = "2024" [dependencies] base64 = "0.22" diff --git a/src/drop_image_file.rs b/src/drop_image_file.rs index e0c1f34..ae63916 100644 --- a/src/drop_image_file.rs +++ b/src/drop_image_file.rs @@ -13,7 +13,8 @@ pub struct DropImageFile { #[derive(Clone)] pub struct DropImageFileParams { - pub callback: Option>, + /// Custom callback when new image dropped, leave empty to automatically set/unset `item` + pub callback: Option)>>, pub revert_label: String, pub cancel_label: String, pub no_image_text: String, @@ -63,6 +64,7 @@ impl DropImageFile { ); let item_clone = self.item.clone(); let params = self.params.clone(); + let callback = self.params.callback.clone(); let image_view = view_deps.render_value(move |(original, item, base64_date)| match item { Some(item) => { let message = format_line(&item); @@ -70,7 +72,13 @@ impl DropImageFile { display: flex; flex-flow: column; "}; - let restore = bind!(item_clone, |_| item_clone.set(None)); + let restore = bind!(item_clone, callback, |_| { + if let Some(callback) = &callback { + callback(None); + } else { + item_clone.set(None); + } + }); let restore_text = if original.is_some() { ¶ms.revert_label } else { @@ -97,11 +105,9 @@ impl DropImageFile { let on_dropfile = move |event: DropFileEvent| { for file in event.items.into_iter() { if let Some(callback) = callback.as_deref() { - callback(file); + callback(Some(file)); } else { - item.change(|current| { - *current = Some(file); - }); + item.set(Some(file)); } } }; diff --git a/src/form/field.rs b/src/form/field.rs index 87d39bd..633146c 100644 --- a/src/form/field.rs +++ b/src/form/field.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, rc::Rc}; -use vertigo::{Computed, Context, DropFileItem, Value}; +use vertigo::{Computed, Context, DomNode, DropFileItem, Value}; -use crate::image_as_uri; +use crate::{image_as_uri, nonify, DropImageFileParams}; #[derive(Clone)] pub struct StringValue { @@ -9,24 +9,46 @@ pub struct StringValue { pub original_value: Rc, } +#[derive(Clone)] +pub struct TextAreaValue { + pub value: Value, + pub original_value: Option>, + pub rows: Option, + pub cols: Option, +} + #[derive(Clone)] pub struct ListValue { pub value: Value, - pub original_value: Rc, + pub original_value: Option>, pub options: Computed>, } #[derive(Clone)] pub struct DictValue { pub value: Value, - pub original_value: Rc, + pub original_value: Option>, pub options: Computed>, } +#[derive(Clone)] +pub struct BoolValue { + pub value: Value, + pub original_value: Option>, +} + #[derive(Clone)] pub struct ImageValue { pub value: Value>, pub original_link: Option>, + pub component_params: Option, +} + +#[derive(Clone)] +pub struct CustomValue { + pub value: Value, + pub original_value: Option>, + pub render: Rc DomNode>, } /// Value of a field in form section. @@ -34,26 +56,39 @@ pub struct ImageValue { pub enum DataFieldValue { /// Regular string field. String(StringValue), + /// Textarea string field. + TextArea(TextAreaValue), /// String field with options. List(ListValue), /// Integer (foreign key) field with labels for each integer. Dict(DictValue), + /// Checkbox + Bool(BoolValue), /// Image (bytes) field. Image(ImageValue), + /// Custom field + Custom(CustomValue), + /// Custom component without value + StaticCustom(Rc DomNode>) } impl DataFieldValue { pub fn export(&self, ctx: &Context) -> FieldExport { match self { Self::String(val) => FieldExport::String(val.value.get(ctx)), + Self::TextArea(val) => FieldExport::String(val.value.get(ctx)), Self::List(val) => FieldExport::List(val.value.get(ctx)), Self::Dict(val) => FieldExport::Dict(val.value.get(ctx)), + Self::Bool(val) => FieldExport::Bool(val.value.get(ctx)), Self::Image(val) => FieldExport::Image((val.original_link.clone(), val.value.get(ctx))), + Self::Custom(val) => FieldExport::String(val.value.get(ctx)), + Self::StaticCustom(_) => FieldExport::String("".to_string()) } } } pub enum FieldExport { + Bool(bool), String(String), List(String), Dict(i64), @@ -61,11 +96,12 @@ pub enum FieldExport { } /// After form is submitted, it generates an export from every field. This can be used to construct a new model. -pub struct FormExport(HashMap); +#[derive(Clone)] +pub struct FormExport(Rc>); impl FormExport { pub fn new(map: HashMap) -> Self { - Self(map) + Self(Rc::new(map)) } pub fn get<'a>(&'a self, key: &str) -> Option<&'a FieldExport> { @@ -74,19 +110,24 @@ impl FormExport { /// Get value from string input. pub fn get_string(&self, key: &str) -> String { + self.get_string_opt(key) + .unwrap_or_default() + } + + /// Get value from string input or None if empty. + pub fn get_string_opt(&self, key: &str) -> Option { self.get(key) - .map(|export| { + .and_then(|export| { if let FieldExport::String(val) = export { - val.clone() + nonify(val.clone()) } else { - Default::default() + None } }) - .unwrap_or_default() } - // Get value from list field (string). - pub fn list_or_default(&self, key: &str) -> String { + // Get value from list field (string-based). + pub fn list_or_default>(&self, key: &str) -> T { self.get(key) .map(|export| { if let FieldExport::List(val) = export { @@ -96,10 +137,11 @@ impl FormExport { } }) .unwrap_or_default() + .into() } - // Get value from dict field (i64). - pub fn dict_or_default(&self, key: &str) -> i64 { + // Get value from dict field (i64-based). + pub fn dict_or_default>(&self, key: &str) -> T { self.get(key) .map(|export| { if let FieldExport::Dict(val) = export { @@ -109,24 +151,74 @@ impl FormExport { } }) .unwrap_or_default() + .into() + } + + /// Get value from bool input (i. e. checkbox) or false. + pub fn get_bool(&self, key: &str) -> bool { + self.get_bool_opt(key).unwrap_or_default() + } + + /// Get value from bool input (i. e. checkbox) or None. + pub fn get_bool_opt(&self, key: &str) -> Option { + self.get(key) + .and_then(|export| { + if let FieldExport::Bool(val) = export { + Some(*val) + } else { + None + } + }) } // Get new image (base64) or original value from image field. pub fn image_url(&self, key: &str) -> String { + self.image_url_opt(key).unwrap_or_default() + } + + // Get new image (base64) or original value from image field or none. + pub fn image_url_opt(&self, key: &str) -> Option { if let Some(export) = self.get(key) { match export { FieldExport::Image((orig_link, dfi)) => { - dfi.as_ref().map(image_as_uri).unwrap_or_else(|| { + dfi.as_ref().map(image_as_uri).or_else(|| { orig_link .as_ref() .map(|ol| ol.to_string()) - .unwrap_or_default() }) } - _ => String::default(), + _ => None, + } + } else { + None + } + } + + // Get new image (base64) + pub fn image_item_opt(&self, key: &str) -> Option { + if let Some(export) = self.get(key) { + match export { + FieldExport::Image((_orig_link, dfi)) => { + dfi.clone() + } + _ => None, + } + } else { + None + } + } + + // Get original image url + pub fn image_orig_url_opt(&self, key: &str) -> Option { + if let Some(export) = self.get(key) { + match export { + FieldExport::Image((orig_link, _dfi)) => { + orig_link.as_deref().map(|s| s.to_string()) + } + _ => None, } } else { - String::default() + None } } } diff --git a/src/form/form_data.rs b/src/form/form_data.rs index 0f730c7..a246332 100644 --- a/src/form/form_data.rs +++ b/src/form/form_data.rs @@ -1,5 +1,7 @@ use std::{collections::HashMap, rc::Rc}; -use vertigo::{transaction, Computed, DomElement, Value}; +use vertigo::{transaction, Computed, Css, DomElement, Value}; + +use crate::form::field::BoolValue; use super::{ field::{DictValue, ImageValue, ListValue, StringValue}, @@ -39,6 +41,30 @@ use super::{ #[derive(Default)] pub struct FormData { pub sections: Vec, + pub top_controls: ControlsConfig, + pub bottom_controls: ControlsConfig, +} + +#[derive(Default)] +pub struct ControlsConfig { + pub css: Option, + pub submit: bool, + pub delete: bool, +} + +impl ControlsConfig { + pub fn full() -> Self { + Self { + css: None, + submit: true, + delete: true, + } + } + + pub fn with_css(mut self, css: Css) -> Self { + self.css = Some(css); + self + } } impl FormData { @@ -48,6 +74,26 @@ impl FormData { self } + pub fn add_top_controls(mut self) -> Self { + self.top_controls = ControlsConfig::full(); + self + } + + pub fn add_top_controls_styled(mut self, css: Css) -> Self { + self.top_controls = ControlsConfig::full().with_css(css); + self + } + + pub fn add_bottom_controls(mut self) -> Self { + self.bottom_controls = ControlsConfig::full(); + self + } + + pub fn add_bottom_controls_styled(mut self, css: Css) -> Self { + self.bottom_controls = ControlsConfig::full().with_css(css); + self + } + pub fn export(&self) -> FormExport { let mut hash_map = HashMap::new(); transaction(|ctx| { @@ -79,9 +125,12 @@ pub struct DataSection { pub error: Option, pub render: Option) -> DomElement>>, pub fieldset_style: FieldsetStyle, + pub fieldset_css: Option, + pub new_group: bool, } /// A single field in form section. +#[derive(Clone)] pub struct DataField { pub key: String, pub value: DataFieldValue, @@ -116,6 +165,14 @@ impl DataSection { } } + pub fn add_field(mut self, key: impl Into, value: DataFieldValue) -> Self { + self.fields.push(DataField { + key: key.into(), + value, + }); + self + } + /// Add another string field to form section (text input). pub fn add_string_field( mut self, @@ -137,16 +194,16 @@ impl DataSection { pub fn add_list_field( mut self, key: impl Into, - original_value: impl Into, + original_value: Option>, options: Vec, ) -> Self { - let value = original_value.into(); + let value = original_value.map(|s| s.into()); let options = Computed::from(move |_ctx| options.clone()); self.fields.push(DataField { key: key.into(), value: DataFieldValue::List(ListValue { - value: Value::new(value.clone()), - original_value: Rc::new(value), + value: Value::new(value.clone().unwrap_or_default()), + original_value: value.map(Rc::new), options, }), }); @@ -157,32 +214,51 @@ impl DataSection { pub fn add_dict_field( mut self, key: impl Into, - original_value: i64, + original_value: Option, options: Vec<(i64, String)>, ) -> Self { let options = Computed::from(move |_ctx| options.clone()); self.fields.push(DataField { key: key.into(), value: DataFieldValue::Dict(DictValue { - value: Value::new(original_value), - original_value: Rc::new(original_value), + value: Value::new(original_value.unwrap_or_default()), + original_value: original_value.map(Rc::new), options, }), }); self } + /// Add another bool field to form section (checkbox input). + pub fn add_bool_field( + mut self, + key: impl Into, + original_value: Option>, + ) -> Self { + let value = original_value.map(|b| b.into()); + self.fields.push(DataField { + key: key.into(), + value: DataFieldValue::Bool(BoolValue { + value: Value::new(value.unwrap_or_default()), + original_value: value.map(Rc::new), + }), + }); + self + } + /// Add another image field to form section. pub fn add_image_field( mut self, key: impl Into, original_value: Option>, ) -> Self { + let value = original_value.map(|l| l.into()); self.fields.push(DataField { key: key.into(), value: DataFieldValue::Image(ImageValue { value: Value::new(None), - original_link: original_value.map(|link| Rc::new(link.into())), + original_link: value.map(Rc::new), + component_params: None, }), }); self @@ -193,4 +269,16 @@ impl DataSection { self.fieldset_style = fieldset_style; self } + + /// Set [Css] for fields container for this section. + pub fn set_fieldset_css(mut self, fieldset_css: Css) -> Self { + self.fieldset_css = Some(fieldset_css); + self + } + + /// This section starts a new section group (Form adds a horizontal rule) + pub fn starts_new_group(mut self) -> Self { + self.new_group = true; + self + } } diff --git a/src/form/mod.rs b/src/form/mod.rs index e2a0296..d97c786 100644 --- a/src/form/mod.rs +++ b/src/form/mod.rs @@ -6,22 +6,50 @@ //! See story book for examples. use std::rc::Rc; -use vertigo::{bind_rc, component, css, dom, Css}; +use vertigo::{AttrGroup, Css, Value, bind, bind_rc, component, css, dom, dom_element}; -use crate::{input::Input, DictSelect, DropImageFile, DropImageFileParams, Select}; +use crate::{ + DictSelect, DropImageFile, Select, Switch, SwitchParams, ValidationErrors, input::Input, +}; mod field; -pub use field::{DataFieldValue, FieldExport, FormExport}; +pub use field::{DataFieldValue, FieldExport, FormExport, ImageValue, TextAreaValue}; mod form_data; -pub use form_data::{DataField, DataSection, FieldsetStyle, FormData}; +pub use form_data::{ControlsConfig, DataField, DataSection, FieldsetStyle, FormData}; -pub struct FormParams { +pub type ValidateFunc = Rc) -> bool>; + +#[derive(Default, Clone, PartialEq)] +pub enum Operation { + #[default] + None, + Saving, + Success, + Error(String), +} + +#[derive(Clone)] +pub struct FormParams +where + T: From + 'static, +{ pub css: Css, pub add_css: Css, - pub submit_label: String, + pub add_section_css: Css, + pub submit_label: Rc, + pub on_delete: Option>, + pub delete_label: Rc, + pub validate: Option>, + pub validation_errors: Value, + pub operation: Value, + pub saving_label: Rc, + pub saved_label: Rc, } -impl Default for FormParams { +impl Default for FormParams +where + T: From + 'static, +{ fn default() -> Self { Self { css: css! { " @@ -30,7 +58,15 @@ impl Default for FormParams { gap: 5px; " }, add_css: Css::default(), - submit_label: "Submit".to_string(), + add_section_css: Css::default(), + submit_label: Rc::new("Submit".to_string()), + on_delete: None, + delete_label: Rc::new("Delete".to_string()), + validate: None, + validation_errors: Default::default(), + operation: Default::default(), + saving_label: Rc::new("Saving...".to_string()), + saved_label: Rc::new("Saved".to_string()), } } } @@ -41,25 +77,49 @@ pub fn Field<'a>(field: &'a DataField) { DataFieldValue::String(val) => { dom! { } } + DataFieldValue::TextArea(val) => { + let on_input = bind!(val.value, |new_value: String| { + value.set(new_value); + }); + let el = + dom_element! { }; + if let Some(rows) = val.rows { + el.add_attr("rows", rows); + } + if let Some(cols) = val.cols { + el.add_attr("cols", cols); + } + el.into() + } DataFieldValue::List(val) => { dom! { }; + // if checked { + // el.add_attr("checked", "checked"); + // } + // el.into() + // }) + } DataFieldValue::Dict(val) => { dom! { } } DataFieldValue::Image(val) => { + let params = val.component_params.clone().unwrap_or_default(); dom! { } } + DataFieldValue::Custom(val) => (val.render)(), + DataFieldValue::StaticCustom(render) => render(), } } @@ -68,9 +128,16 @@ pub fn Field<'a>(field: &'a DataField) { /// A model needs to implement conversion to [FormData] and from [FormExport] to interoperate with this component. /// /// See [FormData] for description how to manage form structure. +/// +/// Use `f` attribute group to pass anything to underlying

element (ex. `f:css="my_styles"`) #[component] -pub fn ModelForm<'a, T>(model: &'a T, on_submit: Rc, params: FormParams) -where +pub fn ModelForm<'a, T>( + model: &'a T, + on_submit: Rc, + params: FormParams, + f: AttrGroup, + s: AttrGroup, +) where FormData: From<&'a T>, T: From + 'static, { @@ -80,71 +147,201 @@ where on_submit(T::from(form_export)); }); - Form { + let mut form_component = Form { form_data, on_submit, params, } - .mount() + .into_component(); + + form_component.f = f; + form_component.s = s; + + form_component.mount() } /// Renders a form for provided [FormData] that upon "Save" allows to grab updated fields from [FormExport]. /// /// See [FormData] for description how to manage form structure. +/// +/// Use `f` attribute group to pass anything to underlying element (ex. `f:css="my_styles"`) #[component] -pub fn Form(form_data: Rc, on_submit: Rc, params: FormParams) { +pub fn Form( + form_data: Rc, + on_submit: Rc, + params: FormParams, + // form attrs + f: AttrGroup, + // section attrs + s: AttrGroup, +) where + T: From + 'static, +{ let subgrid_css = css! {" display: grid; grid-template-columns: subgrid; grid-column: span 2 / span 2; "}; + let section_css = subgrid_css.clone().extend(params.add_section_css.clone()); let fieldset_flex_css = css! {" display: flex; gap: 5px; "}; - let fields = form_data.sections.iter().map(|section| { + let validation_errors = params.validation_errors.clone(); + + let fields = form_data.sections.iter().flat_map(|section| { + let attrs = s.clone(); + let custom_fieldset_css = section.fieldset_css.clone().unwrap_or_else(|| css! {""}); + if section.fields.len() > 1 { let mut values = vec![]; for (i, field) in section.fields.iter().enumerate() { if section.fieldset_style == FieldsetStyle::Dimensions && i > 0 { values.push(dom! { "x" }); } - values.push(dom! { }); + let val_error = { + let field_key = field.key.to_owned(); + validation_errors.render_value_option(move |errs| { + errs.get(&field_key).map(|err| dom! { {err} }) + }) + }; + values.push(dom! { +
+ + {val_error} +
+ }); } let label = §ion.label; - dom! { -