From 14be1170f266f6bb5c96b2857ca0276c440cd9f9 Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Sun, 19 Sep 2021 13:44:58 +0200 Subject: [PATCH] app: Extract the form component from the user details page --- Cargo.lock | 82 ++++++++- Cargo.toml | 12 +- app/Cargo.toml | 10 +- app/src/components/mod.rs | 1 + app/src/components/user_details.rs | 157 +---------------- app/src/components/user_details_form.rs | 218 ++++++++++++++++++++++++ 6 files changed, 320 insertions(+), 160 deletions(-) create mode 100644 app/src/components/user_details_form.rs diff --git a/Cargo.lock b/Cargo.lock index ec5f6b5..6984743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,6 +1483,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + [[package]] name = "indexmap" version = "1.7.0" @@ -1750,14 +1756,20 @@ dependencies = [ "graphql_client", "http", "jwt", + "lazy_static", "lldap_auth", "rand 0.8.4", + "regex", "serde", "serde_json", + "validator", + "validator_derive", "wasm-bindgen", "web-sys", "yew", "yew-router", + "yew_form", + "yew_form_derive", ] [[package]] @@ -2017,8 +2029,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.3.2" -source = "git+https://github.com/nitnelave/num-bigint/?branch=0.3.2-patch#e56d6cca158ec358a48daadf58c070f40a6976ca" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" dependencies = [ "autocfg 1.0.1", "num-integer", @@ -2876,7 +2889,7 @@ dependencies = [ "log", "md-5", "memchr", - "num-bigint 0.3.2", + "num-bigint 0.3.3", "once_cell", "parking_lot", "percent-encoding", @@ -3439,6 +3452,48 @@ dependencies = [ "getrandom 0.2.3", ] +[[package]] +name = "validator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0f08911ab0fee2c5009580f04615fa868898ee57de10692a45da0c3bcc3e5e" +dependencies = [ + "idna", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_types", +] + +[[package]] +name = "validator_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85135714dba11a1bd0b3eb1744169266f1a38977bf4e3ff5e2e1acb8c2b7eee" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded9d97e1d42327632f5f3bae6403c04886e2de3036261ef42deebd931a6a291" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -3681,6 +3736,27 @@ dependencies = [ "nom 5.1.2", ] +[[package]] +name = "yew_form" +version = "0.1.8" +source = "git+https://github.com/sassman/yew_form/?rev=67050812695b7a8a90b81b0637e347fc6629daed#67050812695b7a8a90b81b0637e347fc6629daed" +dependencies = [ + "validator", + "validator_derive", + "yew", +] + +[[package]] +name = "yew_form_derive" +version = "0.1.8" +source = "git+https://github.com/sassman/yew_form/?rev=67050812695b7a8a90b81b0637e347fc6629daed#67050812695b7a8a90b81b0637e347fc6629daed" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "yew_form", +] + [[package]] name = "zeroize" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index dacb72e..51d81b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,11 @@ members = [ "app" ] -#TODO: remove once https://github.com/rust-num/num-bigint/issues/218 is fixed -[patch.crates-io.num-bigint] -git = 'https://github.com/nitnelave/num-bigint/' -branch = '0.3.2-patch' +# TODO: remove when there's a new release. +[patch.crates-io.yew_form] +git = 'https://github.com/sassman/yew_form/' +rev = '67050812695b7a8a90b81b0637e347fc6629daed' + +[patch.crates-io.yew_form_derive] +git = 'https://github.com/sassman/yew_form/' +rev = '67050812695b7a8a90b81b0637e347fc6629daed' diff --git a/app/Cargo.toml b/app/Cargo.toml index 8026ccb..cc03fb1 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -6,15 +6,21 @@ edition = "2018" [dependencies] anyhow = "1" -http = "0.2.4" +graphql_client = "0.10" +http = "0.2" jwt = "0.13" +lazy_static = "*" rand = "0.8" +regex = "*" serde = "1" serde_json = "1" +validator = "*" +validator_derive = "*" wasm-bindgen = "0.2" yew = "0.18" yew-router = "0.15" -graphql_client = "0.10.0" +yew_form = "0.1.8" +yew_form_derive = "*" [dependencies.web-sys] version = "0.3" diff --git a/app/src/components/mod.rs b/app/src/components/mod.rs index 0c1e872..d8c8778 100644 --- a/app/src/components/mod.rs +++ b/app/src/components/mod.rs @@ -7,4 +7,5 @@ pub mod logout; pub mod router; pub mod select; pub mod user_details; +pub mod user_details_form; pub mod user_table; diff --git a/app/src/components/user_details.rs b/app/src/components/user_details.rs index 780e4ce..5cf3685 100644 --- a/app/src/components/user_details.rs +++ b/app/src/components/user_details.rs @@ -2,10 +2,11 @@ use crate::{ components::{ add_user_to_group::AddUserToGroupComponent, router::{AppRoute, NavButton}, + user_details_form::UserDetailsForm, }, infra::api::HostService, }; -use anyhow::{anyhow, bail, Error, Result}; +use anyhow::{bail, Error, Result}; use graphql_client::GraphQLQuery; use yew::{ prelude::*, @@ -24,16 +25,6 @@ pub struct GetUserDetails; pub type User = get_user_details::GetUserDetailsUser; pub type Group = get_user_details::GetUserDetailsUserGroups; -#[derive(GraphQLQuery)] -#[graphql( - schema_path = "../schema.graphql", - query_path = "queries/update_user.graphql", - response_derives = "Debug", - variables_derives = "Clone", - custom_scalars_module = "crate::infra::graphql" -)] -pub struct UpdateUser; - #[derive(GraphQLQuery)] #[graphql( schema_path = "../schema.graphql", @@ -51,12 +42,8 @@ pub struct UserDetails { /// The user info. If none, the error is in `error`. If `error` is None, then we haven't /// received the server response yet. user: Option, - // Needed for the form. - node_ref: NodeRef, /// Error message displayed to the user. error: Option, - /// The request, while we're waiting for the server to reply. - update_request: Option, /// True iff we just finished updating the user, to display a successful message. update_successful: bool, is_admin: bool, @@ -71,10 +58,6 @@ pub struct UserDetails { pub enum Msg { /// Received the user details response, either the user data or an error. UserDetailsResponse(Result), - /// The user changed some fields and submitted the form for update. - SubmitUserUpdateForm, - /// Response after updating the user's details. - UpdateFinished(Result), SubmitRemoveGroup(Group), RemoveGroupResponse(Result), OnError(Error), @@ -87,11 +70,6 @@ pub struct Props { pub is_admin: bool, } -#[allow(clippy::ptr_arg)] -fn not_empty(s: &String) -> bool { - !s.is_empty() -} - impl UserDetails { fn get_user_details(&mut self) { self._task = HostService::graphql_query::( @@ -108,78 +86,6 @@ impl UserDetails { .ok(); } - fn submit_user_update_form(&mut self) -> Result { - let base_user = self.user.as_ref().unwrap(); - let mut user_input = update_user::UpdateUserInput { - id: self.username.clone(), - email: None, - displayName: None, - firstName: None, - lastName: None, - }; - let mut should_send_form = false; - let email = get_element("email") - .filter(not_empty) - .ok_or_else(|| anyhow!("Missing email"))?; - if base_user.email != email { - should_send_form = true; - user_input.email = Some(email); - } - if base_user.display_name != get_element_or_empty("display_name") { - should_send_form = true; - user_input.displayName = Some(get_element_or_empty("display_name")); - } - if base_user.first_name != get_element_or_empty("first_name") { - should_send_form = true; - user_input.firstName = Some(get_element_or_empty("first_name")); - } - if base_user.last_name != get_element_or_empty("last_name") { - should_send_form = true; - user_input.lastName = Some(get_element_or_empty("last_name")); - } - if !should_send_form { - return Ok(false); - } - self.update_request = Some(user_input.clone()); - let req = update_user::Variables { user: user_input }; - self._task = Some(HostService::graphql_query::( - req, - self.link.callback(Msg::UpdateFinished), - "Error trying to update user", - )?); - Ok(false) - } - - fn user_update_finished(&mut self, r: Result) -> Result { - match r { - Err(e) => return Err(e), - Ok(_) => { - ConsoleService::log("Successfully updated user"); - self.update_successful = true; - let User { - id, - display_name, - first_name, - last_name, - email, - creation_date, - groups, - } = self.user.take().unwrap(); - let new_user = self.update_request.take().unwrap(); - self.user = Some(User { - id, - email: new_user.email.unwrap_or(email), - display_name: new_user.displayName.unwrap_or(display_name), - first_name: new_user.firstName.unwrap_or(first_name), - last_name: new_user.lastName.unwrap_or(last_name), - creation_date, - groups, - }); - } - }; - Ok(true) - } - fn submit_remove_group(&mut self, group: Group) -> Result { self._task = HostService::graphql_query::( remove_user_from_group::Variables { @@ -208,8 +114,6 @@ impl UserDetails { bail!("Error getting user details: {}", e); } }, - Msg::SubmitUserUpdateForm => return self.submit_user_update_form(), - Msg::UpdateFinished(r) => return self.user_update_finished(r), Msg::SubmitRemoveGroup(group) => return self.submit_remove_group(group), Msg::RemoveGroupResponse(response) => { response?; @@ -225,39 +129,6 @@ impl UserDetails { Ok(true) } - fn view_form(&self, u: &User) -> Html { - html! { -
-
- {"User ID: "} - {&u.id} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- {"Creation date: "} - {&u.creation_date.with_timezone(&chrono::Local)} -
-
- -
-
- } - } fn view_messages(&self, error: &Option) -> Html { if self.update_successful { html! { @@ -319,22 +190,6 @@ impl UserDetails { } } -fn get_element(name: &str) -> Option { - use wasm_bindgen::JsCast; - Some( - web_sys::window()? - .document()? - .get_element_by_id(name)? - .dyn_into::() - .ok()? - .value(), - ) -} - -fn get_element_or_empty(name: &str) -> String { - get_element(name).unwrap_or_default() -} - impl Component for UserDetails { type Message = Msg; type Properties = Props; @@ -343,11 +198,9 @@ impl Component for UserDetails { let mut table = Self { link, username: props.username, - node_ref: NodeRef::default(), _task: None, user: None, error: None, - update_request: None, update_successful: false, is_admin: props.is_admin, group_to_remove: None, @@ -379,11 +232,13 @@ impl Component for UserDetails { (Some(u), error) => { html! {
- {self.view_form(u)} + {self.view_messages(error)} {self.view_group_memberships(u)}
- {"Change password"} + {"Change password"}
} diff --git a/app/src/components/user_details_form.rs b/app/src/components/user_details_form.rs new file mode 100644 index 0000000..b213b7e --- /dev/null +++ b/app/src/components/user_details_form.rs @@ -0,0 +1,218 @@ +use crate::{components::user_details::User, infra::api::HostService}; +use anyhow::{Error, Result}; +use graphql_client::GraphQLQuery; +use validator_derive::Validate; +use yew::{ + prelude::*, + services::{fetch::FetchTask, ConsoleService}, +}; +use yew_form_derive::Model; + +lazy_static::lazy_static! { + static ref EMAIL_RE: regex::Regex = regex::Regex::new("^[^@]+@[^@]+\\.[^@]+$").unwrap(); +} + +/// The fields of the form, with the editable details and the constraints. +#[derive(Model, Validate, PartialEq, Clone)] +pub struct UserModel { + #[validate(regex(path = "EMAIL_RE", message = "Enter a valid email"))] + email: String, + #[validate(length(min = 1, message = "Display name is required"))] + display_name: String, + first_name: String, + last_name: String, +} + +/// The GraphQL query sent to the server to update the user details. +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/update_user.graphql", + response_derives = "Debug", + variables_derives = "Clone,PartialEq", + custom_scalars_module = "crate::infra::graphql" +)] +pub struct UpdateUser; + +/// A [yew::Component] to display the user details, with a form allowing to edit them. +pub struct UserDetailsForm { + link: ComponentLink, + props: Props, + form: yew_form::Form, + /// True if we just successfully updated the user, to display a success message. + just_updated: bool, + _task: Option, +} + +pub enum Msg { + /// A form field changed. + Update, + /// The "Submit" button was clicked. + SubmitClicked, + /// We got the response from the server about our update message. + UserUpdated(Result), +} + +#[derive(yew::Properties, Clone, PartialEq)] +pub struct Props { + /// The current user details. + pub user: User, + /// Callback to report errors (e.g. server error). + pub on_error: Callback, +} + +impl Component for UserDetailsForm { + type Message = Msg; + type Properties = Props; + + fn create(props: Self::Properties, link: ComponentLink) -> Self { + let model = UserModel { + email: props.user.email.clone(), + display_name: props.user.display_name.clone(), + first_name: props.user.first_name.clone(), + last_name: props.user.last_name.clone(), + }; + Self { + link, + form: yew_form::Form::new(model), + props, + just_updated: false, + _task: None, + } + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + self.just_updated = false; + match self.handle_msg(msg) { + Err(e) => { + ConsoleService::error(&e.to_string()); + self.props.on_error.emit(e); + true + } + Ok(b) => b, + } + } + + fn change(&mut self, _: Self::Properties) -> ShouldRender { + false + } + + fn view(&self) -> Html { + type Field = yew_form::Field; + html! { + <> +
+
+ {"User ID: "} + {&self.props.user.id} +
+
+ + +
+ {&self.form.field_message("email")} +
+
+
+ + +
+ {&self.form.field_message("display_name")} +
+
+
+ + +
+ {&self.form.field_message("first_name")} +
+
+
+ + +
+ {&self.form.field_message("last_name")} +
+
+
+ {"Creation date: "} + {&self.props.user.creation_date.with_timezone(&chrono::Local)} +
+
+ +
+
+ + + } + } +} + +impl UserDetailsForm { + fn handle_msg(&mut self, msg: ::Message) -> Result { + match msg { + Msg::Update => Ok(true), + Msg::SubmitClicked => self.submit_user_update_form(), + Msg::UserUpdated(response) => self.user_update_finished(response), + } + } + + fn submit_user_update_form(&mut self) -> Result { + let base_user = &self.props.user; + let mut user_input = update_user::UpdateUserInput { + id: self.props.user.id.clone(), + email: None, + displayName: None, + firstName: None, + lastName: None, + }; + let default_user_input = user_input.clone(); + let model = self.form.model(); + let email = model.email; + if base_user.email != email { + user_input.email = Some(email); + } + if base_user.display_name != model.display_name { + user_input.displayName = Some(model.display_name); + } + if base_user.first_name != model.first_name { + user_input.firstName = Some(model.first_name); + } + if base_user.last_name != model.last_name { + user_input.lastName = Some(model.last_name); + } + // Nothing changed. + if user_input == default_user_input { + return Ok(false); + } + let req = update_user::Variables { user: user_input }; + self._task = Some(HostService::graphql_query::( + req, + self.link.callback(Msg::UserUpdated), + "Error trying to update user", + )?); + Ok(false) + } + + fn user_update_finished(&mut self, r: Result) -> Result { + match r { + Err(e) => return Err(e), + Ok(_) => { + let model = self.form.model(); + self.props.user = User { + id: self.props.user.id.clone(), + email: model.email, + display_name: model.display_name, + first_name: model.first_name, + last_name: model.last_name, + creation_date: self.props.user.creation_date, + groups: self.props.user.groups.clone(), + }; + self.just_updated = true; + } + }; + Ok(true) + } +}