diff --git a/app/src/components/app.rs b/app/src/components/app.rs index c7e503f..fbb48c1 100644 --- a/app/src/components/app.rs +++ b/app/src/components/app.rs @@ -11,8 +11,8 @@ use crate::{ reset_password_step1::ResetPasswordStep1Form, reset_password_step2::ResetPasswordStep2Form, router::{AppRoute, Link, Redirect}, - user_attributes_table::UserAttributesTable, user_details::UserDetails, + user_schema_table::ListUserSchema, user_table::UserTable, }, infra::{api::HostService, cookies::get_cookie}, @@ -241,14 +241,8 @@ impl App { }, - AppRoute::ListUserAttributes => html! { -
- - - - {"Create an attribute"} - -
+ AppRoute::ListUserSchema => html! { + }, AppRoute::GroupDetails { group_id } => html! { @@ -308,9 +302,9 @@ impl App {
  • + to={AppRoute::ListUserSchema}> - {"User attributes"} + {"User schema"}
  • diff --git a/app/src/components/create_user_attribute.rs b/app/src/components/create_user_attribute.rs index 6c61305..3171447 100644 --- a/app/src/components/create_user_attribute.rs +++ b/app/src/components/create_user_attribute.rs @@ -1,6 +1,13 @@ use crate::{ - components::router::AppRoute, - infra::common_component::{CommonComponent, CommonComponentParts}, + components::{ + form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit}, + router::AppRoute, + }, + convert_attribute_type, + infra::{ + common_component::{CommonComponent, CommonComponentParts}, + schema::AttributeType, + }, }; use anyhow::{bail, Result}; use gloo_console::log; @@ -19,7 +26,7 @@ use yew_router::{prelude::History, scope_ext::RouterScopeExt}; )] pub struct CreateUserAttribute; -type AttributeType = create_user_attribute::AttributeType; +convert_attribute_type!(create_user_attribute::AttributeType); pub struct CreateUserAttributeForm { common: CommonComponentParts, @@ -59,16 +66,10 @@ impl CommonComponent for CreateUserAttributeForm { if model.is_editable && !model.is_visible { bail!("Editable attributes must also be visible"); } - let attribute_type = match model.attribute_type.as_str() { - "Jpeg" => AttributeType::JPEG_PHOTO, - "DateTime" => AttributeType::DATE_TIME, - "Integer" => AttributeType::INTEGER, - "String" => AttributeType::STRING, - _ => bail!("Check the form for errors"), - }; + let attribute_type = model.attribute_type.parse::().unwrap(); let req = create_user_attribute::Variables { name: model.attribute_name, - attribute_type, + attribute_type: create_user_attribute::AttributeType::from(attribute_type), is_editable: model.is_editable, is_list: model.is_list, is_visible: model.is_visible, @@ -88,10 +89,7 @@ impl CommonComponent for CreateUserAttributeForm { "Created user attribute '{}'", model.attribute_name )); - ctx.link() - .history() - .unwrap() - .push(AppRoute::ListUserAttributes); + ctx.link().history().unwrap().push(AppRoute::ListUserSchema); Ok(true) } } @@ -108,7 +106,7 @@ impl Component for CreateUserAttributeForm { fn create(_: &Context) -> Self { let model = CreateUserAttributeModel { - attribute_type: "String".to_string(), + attribute_type: AttributeType::String.to_string(), ..Default::default() }; Self { @@ -123,105 +121,45 @@ impl Component for CreateUserAttributeForm { fn view(&self, ctx: &Context) -> Html { let link = ctx.link(); - type Field = yew_form::Field; - type Select = yew_form::Select; - type Checkbox = yew_form::CheckBox; html! {
    -
    -
    {"Create a user attribute"}
    -
    -
    - -
    - -
    - {&self.form.field_message("attribute_name")} -
    -
    -
    -
    - -
    - -
    - {&self.form.field_message("attribute_type")} -
    -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    +
    {"Create a user attribute"}
    + + label="Name" + required={true} + form={&self.form} + field_name="attribute_name" + oninput={link.callback(|_| Msg::Update)} /> + + label="Type" + required={true} + form={&self.form} + field_name="attribute_name" + oninput={link.callback(|_| Msg::Update)}> + + + + + > + + label="Multiple values" + form={&self.form} + field_name="is_list" + ontoggle={link.callback(|_| Msg::Update)} /> + + label="Visible to users" + form={&self.form} + field_name="is_visible" + ontoggle={link.callback(|_| Msg::Update)} /> + + label="Editable by users" + form={&self.form} + field_name="is_editable" + ontoggle={link.callback(|_| Msg::Update)} /> + { if let Some(e) = &self.common.error { html! { diff --git a/app/src/components/form/checkbox.rs b/app/src/components/form/checkbox.rs new file mode 100644 index 0000000..6697421 --- /dev/null +++ b/app/src/components/form/checkbox.rs @@ -0,0 +1,35 @@ +use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties}; +use yew_form::{Form, Model}; + +#[derive(Properties, PartialEq)] +pub struct Props { + pub label: AttrValue, + pub field_name: String, + pub form: Form, + #[prop_or(false)] + pub required: bool, + #[prop_or_else(Callback::noop)] + pub ontoggle: Callback, +} + +#[function_component(CheckBox)] +pub fn checkbox(props: &Props) -> Html { + html! { +
    + +
    + + form={&props.form} + field_name={props.field_name.clone()} + ontoggle={props.ontoggle.clone()} /> +
    +
    + } +} diff --git a/app/src/components/form/field.rs b/app/src/components/form/field.rs new file mode 100644 index 0000000..ab5018f --- /dev/null +++ b/app/src/components/form/field.rs @@ -0,0 +1,42 @@ +use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties}; +use yew_form::{Form, Model}; + +#[derive(Properties, PartialEq)] +pub struct Props { + pub label: AttrValue, + pub field_name: String, + pub form: Form, + #[prop_or(false)] + pub required: bool, + #[prop_or_else(Callback::noop)] + pub oninput: Callback, +} + +#[function_component(Field)] +pub fn field(props: &Props) -> Html { + html! { +
    + +
    + + form={&props.form} + field_name={props.field_name.clone()} + class="form-control" + class_invalid="is-invalid has-error" + class_valid="has-success" + autocomplete={props.field_name.clone()} + oninput={&props.oninput} /> +
    + {&props.form.field_message(&props.field_name)} +
    +
    +
    + } +} diff --git a/app/src/components/form/mod.rs b/app/src/components/form/mod.rs new file mode 100644 index 0000000..dc112e3 --- /dev/null +++ b/app/src/components/form/mod.rs @@ -0,0 +1,4 @@ +pub mod checkbox; +pub mod field; +pub mod select; +pub mod submit; diff --git a/app/src/components/form/select.rs b/app/src/components/form/select.rs new file mode 100644 index 0000000..1254214 --- /dev/null +++ b/app/src/components/form/select.rs @@ -0,0 +1,46 @@ +use yew::{ + function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties, +}; +use yew_form::{Form, Model}; + +#[derive(Properties, PartialEq)] +pub struct Props { + pub label: AttrValue, + pub field_name: String, + pub form: Form, + #[prop_or(false)] + pub required: bool, + #[prop_or_else(Callback::noop)] + pub oninput: Callback, + pub children: Children, +} + +#[function_component(Select)] +pub fn select(props: &Props) -> Html { + html! { +
    + +
    + + form={&props.form} + class="form-control" + class_invalid="is-invalid has-error" + class_valid="has-success" + field_name={props.field_name.clone()} + oninput={&props.oninput} > + {for props.children.iter()} + > +
    + {&props.form.field_message(&props.field_name)} +
    +
    +
    + } +} diff --git a/app/src/components/form/submit.rs b/app/src/components/form/submit.rs new file mode 100644 index 0000000..622917f --- /dev/null +++ b/app/src/components/form/submit.rs @@ -0,0 +1,24 @@ +use web_sys::MouseEvent; +use yew::{function_component, html, Callback, Properties}; + +#[derive(Properties, PartialEq)] +pub struct Props { + pub disabled: bool, + pub onclick: Callback, +} + +#[function_component(Submit)] +pub fn submit(props: &Props) -> Html { + html! { +
    + +
    + } +} diff --git a/app/src/components/mod.rs b/app/src/components/mod.rs index 26e01ed..0fe1c13 100644 --- a/app/src/components/mod.rs +++ b/app/src/components/mod.rs @@ -8,6 +8,7 @@ pub mod create_user_attribute; pub mod delete_group; pub mod delete_user; pub mod delete_user_attribute; +pub mod form; pub mod group_details; pub mod group_table; pub mod login; @@ -17,7 +18,7 @@ pub mod reset_password_step1; pub mod reset_password_step2; pub mod router; pub mod select; -pub mod user_attributes_table; pub mod user_details; pub mod user_details_form; +pub mod user_schema_table; pub mod user_table; diff --git a/app/src/components/router.rs b/app/src/components/router.rs index 7ff3174..09e7782 100644 --- a/app/src/components/router.rs +++ b/app/src/components/router.rs @@ -22,12 +22,12 @@ pub enum AppRoute { ListGroups, #[at("/group/:group_id")] GroupDetails { group_id: i64 }, - #[at("/")] - Index, #[at("/user-attributes")] - ListUserAttributes, + ListUserSchema, #[at("/user-attributes/create")] CreateUserAttribute, + #[at("/")] + Index, } pub type Link = yew_router::components::Link; diff --git a/app/src/components/user_attributes_table.rs b/app/src/components/user_schema_table.rs similarity index 56% rename from app/src/components/user_attributes_table.rs rename to app/src/components/user_schema_table.rs index e0658e5..0c9a817 100644 --- a/app/src/components/user_attributes_table.rs +++ b/app/src/components/user_schema_table.rs @@ -1,8 +1,18 @@ +use std::cmp::Ordering; + use crate::{ - components::delete_user_attribute::DeleteUserAttribute, - infra::common_component::{CommonComponent, CommonComponentParts}, + components::{ + delete_user_attribute::DeleteUserAttribute, + router::{AppRoute, Link}, + }, + convert_attribute_type, + infra::{ + common_component::{CommonComponent, CommonComponentParts}, + schema::AttributeType, + }, }; -use anyhow::{Error, Result}; +use anyhow::{anyhow, Error, Result}; +use gloo_console::log; use graphql_client::GraphQLQuery; use yew::prelude::*; @@ -18,9 +28,20 @@ pub struct GetUserAttributesSchema; use get_user_attributes_schema::ResponseData; pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes; -pub type AttributeType = get_user_attributes_schema::AttributeType; -pub struct UserAttributesTable { +convert_attribute_type!(get_user_attributes_schema::AttributeType); + +fn sort_with_hardcoded_first(a: &Attribute, b: &Attribute) -> Ordering { + if a.is_hardcoded && !b.is_hardcoded { + Ordering::Less + } else if !a.is_hardcoded && b.is_hardcoded { + Ordering::Greater + } else { + a.name.cmp(&b.name) + } +} + +pub struct UserSchemaTable { common: CommonComponentParts, attributes: Option>, } @@ -31,7 +52,7 @@ pub enum Msg { OnError(Error), } -impl CommonComponent for UserAttributesTable { +impl CommonComponent for UserSchemaTable { fn handle_msg(&mut self, _: &Context, msg: ::Message) -> Result { match msg { Msg::ListAttributesResponse(schema) => { @@ -39,14 +60,19 @@ impl CommonComponent for UserAttributesTable { Ok(true) } Msg::OnError(e) => Err(e), - Msg::OnAttributeDeleted(attribute_name) => { - debug_assert!(self.attributes.is_some()); - self.attributes - .as_mut() - .unwrap() - .retain(|a| a.name != attribute_name); - Ok(true) - } + Msg::OnAttributeDeleted(attribute_name) => match self.attributes { + None => { + log!("Attribute deleted but component has no attributes"); + Err(anyhow!("invalid state")) + } + Some(_) => { + self.attributes + .as_mut() + .unwrap() + .retain(|a| a.name != attribute_name); + Ok(true) + } + }, } } @@ -55,12 +81,12 @@ impl CommonComponent for UserAttributesTable { } } -impl Component for UserAttributesTable { +impl Component for UserSchemaTable { type Message = Msg; type Properties = (); fn create(ctx: &Context) -> Self { - let mut table = UserAttributesTable { + let mut table = UserSchemaTable { common: CommonComponentParts::::create(), attributes: None, }; @@ -87,7 +113,7 @@ impl Component for UserAttributesTable { } } -impl UserAttributesTable { +impl UserSchemaTable { fn view_attributes(&self, ctx: &Context) -> Html { let make_table = |attributes: &Vec| { html! { @@ -111,43 +137,35 @@ impl UserAttributesTable { }; match &self.attributes { None => html! {{"Loading..."}}, - Some(attributes) => make_table(attributes), + Some(attributes) => { + let mut attributes = attributes.clone(); + attributes.sort_by(sort_with_hardcoded_first); + make_table(&attributes) + } } } fn view_attribute(&self, ctx: &Context, attribute: &Attribute) -> Html { let link = ctx.link(); - let attribute_type = match attribute.attribute_type { - AttributeType::STRING => "String", - AttributeType::INTEGER => "Integer", - AttributeType::JPEG_PHOTO => "Jpeg", - AttributeType::DATE_TIME => "DateTime", - _ => "Unknown", - }; + let attribute_type = AttributeType::from(attribute.attribute_type.clone()); let checkmark = html! { }; html! { - - {&attribute.name} - {if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}} - {if attribute.is_editable {checkmark.clone()} else {html!{}}} - {if attribute.is_visible {checkmark.clone()} else {html!{}}} - {if attribute.is_hardcoded {html!{ - - }} else {html!{ - - }}} - + + {&attribute.name} + {if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}} + {if attribute.is_editable {checkmark.clone()} else {html!{}}} + {if attribute.is_visible {checkmark.clone()} else {html!{}}} + {if attribute.is_hardcoded {html!{}} else { html!{ + + }}} + } } @@ -158,3 +176,16 @@ impl UserAttributesTable { } } } + +#[function_component(ListUserSchema)] +pub fn list_user_schema() -> Html { + html! { +
    + + + + {"Create an attribute"} + +
    + } +} diff --git a/app/src/infra/mod.rs b/app/src/infra/mod.rs index 2e58c62..663ee08 100644 --- a/app/src/infra/mod.rs +++ b/app/src/infra/mod.rs @@ -3,3 +3,4 @@ pub mod common_component; pub mod cookies; pub mod graphql; pub mod modal; +pub mod schema; diff --git a/app/src/infra/schema.rs b/app/src/infra/schema.rs new file mode 100644 index 0000000..3ef8db2 --- /dev/null +++ b/app/src/infra/schema.rs @@ -0,0 +1,59 @@ +use anyhow::Result; +use std::{fmt::Display, str::FromStr}; + +#[derive(Debug)] +pub enum AttributeType { + String, + Integer, + DateTime, + Jpeg, +} + +impl Display for AttributeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl FromStr for AttributeType { + type Err = (); + fn from_str(value: &str) -> Result { + match value { + "String" => Ok(AttributeType::String), + "Integer" => Ok(AttributeType::Integer), + "DateTime" => Ok(AttributeType::DateTime), + "Jpeg" => Ok(AttributeType::Jpeg), + _ => Err(()), + } + } +} + +// Macro to generate traits for converting between AttributeType and the +// graphql generated equivalents. +#[macro_export] +macro_rules! convert_attribute_type { + ($source_type:ty) => { + impl From<$source_type> for AttributeType { + fn from(value: $source_type) -> Self { + match value { + <$source_type>::STRING => AttributeType::String, + <$source_type>::INTEGER => AttributeType::Integer, + <$source_type>::DATE_TIME => AttributeType::DateTime, + <$source_type>::JPEG_PHOTO => AttributeType::Jpeg, + _ => panic!("Unknown attribute type"), + } + } + } + + impl From for $source_type { + fn from(value: AttributeType) -> Self { + match value { + AttributeType::String => <$source_type>::STRING, + AttributeType::Integer => <$source_type>::INTEGER, + AttributeType::DateTime => <$source_type>::DATE_TIME, + AttributeType::Jpeg => <$source_type>::JPEG_PHOTO, + } + } + } + }; +}