diff --git a/Cargo.lock b/Cargo.lock index 72e6163..31524a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2159,6 +2159,7 @@ name = "lldap_app" version = "0.4.0" dependencies = [ "anyhow", + "base64", "chrono", "graphql_client 0.10.0", "http", diff --git a/app/Cargo.toml b/app/Cargo.toml index f02401f..5ff4197 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = "1" +base64 = "0.13" graphql_client = "0.10" http = "0.2" jwt = "0.13" @@ -27,6 +28,7 @@ version = "0.3" features = [ "Document", "Element", + "FileReader", "HtmlDocument", "HtmlInputElement", "HtmlOptionElement", diff --git a/app/queries/get_user_details.graphql b/app/queries/get_user_details.graphql index f476f2d..97370fb 100644 --- a/app/queries/get_user_details.graphql +++ b/app/queries/get_user_details.graphql @@ -5,6 +5,7 @@ query GetUserDetails($id: String!) { displayName firstName lastName + avatar creationDate uuid groups { diff --git a/app/src/components/user_details.rs b/app/src/components/user_details.rs index 2f57af0..67ab078 100644 --- a/app/src/components/user_details.rs +++ b/app/src/components/user_details.rs @@ -198,8 +198,7 @@ impl Component for UserDetails { <>

{u.id.to_string()}

+ user=u.clone() />
, + contents: Option>, +} + +impl ToString for JsFile { + fn to_string(&self) -> String { + self.file + .as_ref() + .map(web_sys::File::name) + .unwrap_or_else(String::new) + } +} + +impl FromStr for JsFile { + type Err = Error; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + Ok(JsFile::default()) + } else { + bail!("Building file from non-empty string") + } + } +} + /// The fields of the form, with the editable details and the constraints. #[derive(Model, Validate, PartialEq, Clone)] pub struct UserModel { @@ -34,6 +64,7 @@ pub struct UpdateUser; pub struct UserDetailsForm { common: CommonComponentParts, form: yew_form::Form, + avatar: JsFile, /// True if we just successfully updated the user, to display a success message. just_updated: bool, } @@ -43,6 +74,8 @@ pub enum Msg { Update, /// The "Submit" button was clicked. SubmitClicked, + /// A picked file finished loading. + FileLoaded(yew::services::reader::FileData), /// We got the response from the server about our update message. UserUpdated(Result), } @@ -51,16 +84,58 @@ pub enum Msg { pub struct Props { /// The current user details. pub user: User, - /// Callback to report errors (e.g. server error). - pub on_error: Callback, } impl CommonComponent for UserDetailsForm { fn handle_msg(&mut self, msg: ::Message) -> Result { match msg { - Msg::Update => Ok(true), + Msg::Update => { + let window = web_sys::window().expect("no global `window` exists"); + let document = window.document().expect("should have a document on window"); + let input = document + .get_element_by_id("avatarInput") + .expect("Form field avatarInput should be present") + .dyn_into::() + .expect("Should be an HtmlInputElement"); + ConsoleService::log("Form update"); + if let Some(files) = input.files() { + ConsoleService::log("Got file list"); + if files.length() > 0 { + ConsoleService::log("Got a file"); + let new_avatar = JsFile { + file: files.item(0), + contents: None, + }; + if self.avatar.file.as_ref().map(|f| f.name()) + != new_avatar.file.as_ref().map(|f| f.name()) + { + if let Some(ref file) = new_avatar.file { + self.mut_common().read_file(file.clone(), Msg::FileLoaded)?; + } + self.avatar = new_avatar; + } + } + } + Ok(true) + } Msg::SubmitClicked => self.submit_user_update_form(), Msg::UserUpdated(response) => self.user_update_finished(response), + Msg::FileLoaded(data) => { + self.common.cancel_task(); + if let Some(file) = &self.avatar.file { + if file.name() == data.name { + if !is_valid_jpeg(data.content.as_slice()) { + // Clear the selection. + self.avatar = JsFile::default(); + bail!("Chosen image is not a valid JPEG"); + } else { + self.avatar.contents = Some(data.content); + return Ok(true); + } + } + } + Ok(false) + } } } @@ -83,17 +158,14 @@ impl Component for UserDetailsForm { Self { common: CommonComponentParts::::create(props, link), form: yew_form::Form::new(model), + avatar: JsFile::default(), just_updated: false, } } fn update(&mut self, msg: Self::Message) -> ShouldRender { self.just_updated = false; - CommonComponentParts::::update_and_report_error( - self, - msg, - self.common.on_error.clone(), - ) + CommonComponentParts::::update(self, msg) } fn change(&mut self, props: Self::Properties) -> ShouldRender { @@ -102,6 +174,9 @@ impl Component for UserDetailsForm { fn view(&self) -> Html { type Field = yew_form::Field; + + let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default(); + let avatar_string = avatar_base64.as_ref().unwrap_or(&self.common.user.avatar); html! {
@@ -111,7 +186,24 @@ impl Component for UserDetailsForm { {"User ID: "}
- {&self.common.user.id} + {&self.common.user.id} +
+
+
+
+ Avatar +
+
+
@@ -214,6 +306,14 @@ impl Component for UserDetailsForm {
+ { if let Some(e) = &self.common.error { + html! { +
+ {e.to_string() } +
+ } + } else { html! {} } + } @@ -224,9 +324,19 @@ impl Component for UserDetailsForm { impl UserDetailsForm { fn submit_user_update_form(&mut self) -> Result { + ConsoleService::log("Submit"); if !self.form.validate() { bail!("Invalid inputs"); } + ConsoleService::log("Valid inputs"); + if let JsFile { + file: Some(_), + contents: None, + } = &self.avatar + { + bail!("Image file hasn't finished loading, try again"); + } + ConsoleService::log("File is correctly loaded"); let base_user = &self.common.user; let mut user_input = update_user::UpdateUserInput { id: self.common.user.id.clone(), @@ -251,11 +361,14 @@ impl UserDetailsForm { if base_user.last_name != model.last_name { user_input.lastName = Some(model.last_name); } + user_input.avatar = maybe_to_base64(&self.avatar)?; // Nothing changed. if user_input == default_user_input { + ConsoleService::log("No changes"); return Ok(false); } let req = update_user::Variables { user: user_input }; + ConsoleService::log("Querying"); self.common.call_graphql::( req, Msg::UserUpdated, @@ -270,19 +383,44 @@ impl UserDetailsForm { Err(e) => return Err(e), Ok(_) => { let model = self.form.model(); - self.common.user = User { - id: self.common.user.id.clone(), - email: model.email, - display_name: model.display_name, - first_name: model.first_name, - last_name: model.last_name, - creation_date: self.common.user.creation_date, - uuid: self.common.user.uuid.clone(), - groups: self.common.user.groups.clone(), - }; + self.common.user.email = model.email; + self.common.user.display_name = model.display_name; + self.common.user.first_name = model.first_name; + self.common.user.last_name = model.last_name; + if let Some(avatar) = maybe_to_base64(&self.avatar)? { + self.common.user.avatar = avatar; + } self.just_updated = true; } }; Ok(true) } } + +fn is_valid_jpeg(bytes: &[u8]) -> bool { + image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg) + .decode() + .is_ok() +} + +fn maybe_to_base64(file: &JsFile) -> Result> { + match file { + JsFile { + file: None, + contents: _, + } => Ok(None), + JsFile { + file: Some(_), + contents: None, + } => bail!("Image file hasn't finished loading, try again"), + JsFile { + file: Some(_), + contents: Some(data), + } => { + if !is_valid_jpeg(data.as_slice()) { + bail!("Chosen image is not a valid JPEG"); + } + Ok(Some(base64::encode(data))) + } + } +} diff --git a/app/src/infra/common_component.rs b/app/src/infra/common_component.rs index 99494f8..bb2c68e 100644 --- a/app/src/infra/common_component.rs +++ b/app/src/infra/common_component.rs @@ -26,7 +26,11 @@ use anyhow::{Error, Result}; use graphql_client::GraphQLQuery; use yew::{ prelude::*, - services::{fetch::FetchTask, ConsoleService}, + services::{ + fetch::FetchTask, + reader::{FileData, ReaderService, ReaderTask}, + ConsoleService, + }, }; use yewtil::NeqAssign; @@ -40,13 +44,34 @@ pub trait CommonComponent>: Component { fn mut_common(&mut self) -> &mut CommonComponentParts; } +enum AnyTask { + None, + FetchTask(FetchTask), + ReaderTask(ReaderTask), +} + +impl AnyTask { + fn is_some(&self) -> bool { + !matches!(self, AnyTask::None) + } +} + +impl From> for AnyTask { + fn from(task: Option) -> Self { + match task { + Some(t) => AnyTask::FetchTask(t), + None => AnyTask::None, + } + } +} + /// Structure that contains the common parts needed by most components. /// The fields of [`props`] are directly accessible through a `Deref` implementation. pub struct CommonComponentParts> { link: ComponentLink, pub props: ::Properties, pub error: Option, - task: Option, + task: AnyTask, } impl> CommonComponentParts { @@ -57,7 +82,7 @@ impl> CommonComponentParts { /// Cancel any background task. pub fn cancel_task(&mut self) { - self.task = None; + self.task = AnyTask::None; } pub fn create(props: ::Properties, link: ComponentLink) -> Self { @@ -65,7 +90,7 @@ impl> CommonComponentParts { link, props, error: None, - task: None, + task: AnyTask::None, } } @@ -131,7 +156,7 @@ impl> CommonComponentParts { M: Fn(Req, Callback) -> Result, Cb: FnOnce(Resp) -> ::Message + 'static, { - self.task = Some(method(req, self.link.callback_once(callback))?); + self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?); Ok(()) } @@ -156,7 +181,19 @@ impl> CommonComponentParts { ConsoleService::log(&e.to_string()); self.error = Some(e); }) - .ok(); + .ok() + .into(); + } + + pub(crate) fn read_file(&mut self, file: web_sys::File, callback: Cb) -> Result<()> + where + Cb: FnOnce(FileData) -> ::Message + 'static, + { + self.task = AnyTask::ReaderTask(ReaderService::read_file( + file, + self.link.callback_once(callback), + )?); + Ok(()) } }