app: Add support for modifying an avatar

This commit is contained in:
Valentin Tolmer 2022-08-08 17:36:50 +02:00 committed by nitnelave
parent 60c594438c
commit 686bdc0cb1
6 changed files with 206 additions and 28 deletions

1
Cargo.lock generated
View File

@ -2159,6 +2159,7 @@ name = "lldap_app"
version = "0.4.0" version = "0.4.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"chrono", "chrono",
"graphql_client 0.10.0", "graphql_client 0.10.0",
"http", "http",

View File

@ -6,6 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
base64 = "0.13"
graphql_client = "0.10" graphql_client = "0.10"
http = "0.2" http = "0.2"
jwt = "0.13" jwt = "0.13"
@ -27,6 +28,7 @@ version = "0.3"
features = [ features = [
"Document", "Document",
"Element", "Element",
"FileReader",
"HtmlDocument", "HtmlDocument",
"HtmlInputElement", "HtmlInputElement",
"HtmlOptionElement", "HtmlOptionElement",

View File

@ -5,6 +5,7 @@ query GetUserDetails($id: String!) {
displayName displayName
firstName firstName
lastName lastName
avatar
creationDate creationDate
uuid uuid
groups { groups {

View File

@ -198,8 +198,7 @@ impl Component for UserDetails {
<> <>
<h3>{u.id.to_string()}</h3> <h3>{u.id.to_string()}</h3>
<UserDetailsForm <UserDetailsForm
user=u.clone() user=u.clone() />
on_error=self.common.callback(Msg::OnError)/>
<div class="row justify-content-center"> <div class="row justify-content-center">
<NavButton <NavButton
route=AppRoute::ChangePassword(u.id.clone()) route=AppRoute::ChangePassword(u.id.clone())

View File

@ -1,3 +1,5 @@
use std::str::FromStr;
use crate::{ use crate::{
components::user_details::User, components::user_details::User,
infra::common_component::{CommonComponent, CommonComponentParts}, infra::common_component::{CommonComponent, CommonComponentParts},
@ -5,9 +7,37 @@ use crate::{
use anyhow::{bail, Error, Result}; use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use validator_derive::Validate; use validator_derive::Validate;
use yew::prelude::*; use wasm_bindgen::JsCast;
use yew::{prelude::*, services::ConsoleService};
use yew_form_derive::Model; use yew_form_derive::Model;
#[derive(PartialEq, Clone, Default)]
struct JsFile {
file: Option<web_sys::File>,
contents: Option<Vec<u8>>,
}
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<Self> {
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. /// The fields of the form, with the editable details and the constraints.
#[derive(Model, Validate, PartialEq, Clone)] #[derive(Model, Validate, PartialEq, Clone)]
pub struct UserModel { pub struct UserModel {
@ -34,6 +64,7 @@ pub struct UpdateUser;
pub struct UserDetailsForm { pub struct UserDetailsForm {
common: CommonComponentParts<Self>, common: CommonComponentParts<Self>,
form: yew_form::Form<UserModel>, form: yew_form::Form<UserModel>,
avatar: JsFile,
/// True if we just successfully updated the user, to display a success message. /// True if we just successfully updated the user, to display a success message.
just_updated: bool, just_updated: bool,
} }
@ -43,6 +74,8 @@ pub enum Msg {
Update, Update,
/// The "Submit" button was clicked. /// The "Submit" button was clicked.
SubmitClicked, SubmitClicked,
/// A picked file finished loading.
FileLoaded(yew::services::reader::FileData),
/// We got the response from the server about our update message. /// We got the response from the server about our update message.
UserUpdated(Result<update_user::ResponseData>), UserUpdated(Result<update_user::ResponseData>),
} }
@ -51,16 +84,58 @@ pub enum Msg {
pub struct Props { pub struct Props {
/// The current user details. /// The current user details.
pub user: User, pub user: User,
/// Callback to report errors (e.g. server error).
pub on_error: Callback<Error>,
} }
impl CommonComponent<UserDetailsForm> for UserDetailsForm { impl CommonComponent<UserDetailsForm> for UserDetailsForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg { 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::<web_sys::HtmlInputElement>()
.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::SubmitClicked => self.submit_user_update_form(),
Msg::UserUpdated(response) => self.user_update_finished(response), 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 { Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(props, link),
form: yew_form::Form::new(model), form: yew_form::Form::new(model),
avatar: JsFile::default(),
just_updated: false, just_updated: false,
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.just_updated = false; self.just_updated = false;
CommonComponentParts::<Self>::update_and_report_error( CommonComponentParts::<Self>::update(self, msg)
self,
msg,
self.common.on_error.clone(),
)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn change(&mut self, props: Self::Properties) -> ShouldRender {
@ -102,6 +174,9 @@ impl Component for UserDetailsForm {
fn view(&self) -> Html { fn view(&self) -> Html {
type Field = yew_form::Field<UserModel>; type Field = yew_form::Field<UserModel>;
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! { html! {
<div class="py-3"> <div class="py-3">
<form class="form"> <form class="form">
@ -111,7 +186,24 @@ impl Component for UserDetailsForm {
{"User ID: "} {"User ID: "}
</label> </label>
<div class="col-8"> <div class="col-8">
<span id="userId" class="form-constrol-static">{&self.common.user.id}</span> <span id="userId" class="form-constrol-static"><b>{&self.common.user.id}</b></span>
</div>
</div>
<div class="form-group row mb-3">
<div class="col-4 col-form-label">
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
</div>
<div class="col-8">
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput=self.common.callback(|_| Msg::Update) />
</div> </div>
</div> </div>
<div class="form-group row mb-3"> <div class="form-group row mb-3">
@ -214,6 +306,14 @@ impl Component for UserDetailsForm {
</button> </button>
</div> </div>
</form> </form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
<div hidden=!self.just_updated> <div hidden=!self.just_updated>
<span>{"User successfully updated!"}</span> <span>{"User successfully updated!"}</span>
</div> </div>
@ -224,9 +324,19 @@ impl Component for UserDetailsForm {
impl UserDetailsForm { impl UserDetailsForm {
fn submit_user_update_form(&mut self) -> Result<bool> { fn submit_user_update_form(&mut self) -> Result<bool> {
ConsoleService::log("Submit");
if !self.form.validate() { if !self.form.validate() {
bail!("Invalid inputs"); 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 base_user = &self.common.user;
let mut user_input = update_user::UpdateUserInput { let mut user_input = update_user::UpdateUserInput {
id: self.common.user.id.clone(), id: self.common.user.id.clone(),
@ -251,11 +361,14 @@ impl UserDetailsForm {
if base_user.last_name != model.last_name { if base_user.last_name != model.last_name {
user_input.lastName = Some(model.last_name); user_input.lastName = Some(model.last_name);
} }
user_input.avatar = maybe_to_base64(&self.avatar)?;
// Nothing changed. // Nothing changed.
if user_input == default_user_input { if user_input == default_user_input {
ConsoleService::log("No changes");
return Ok(false); return Ok(false);
} }
let req = update_user::Variables { user: user_input }; let req = update_user::Variables { user: user_input };
ConsoleService::log("Querying");
self.common.call_graphql::<UpdateUser, _>( self.common.call_graphql::<UpdateUser, _>(
req, req,
Msg::UserUpdated, Msg::UserUpdated,
@ -270,19 +383,44 @@ impl UserDetailsForm {
Err(e) => return Err(e), Err(e) => return Err(e),
Ok(_) => { Ok(_) => {
let model = self.form.model(); let model = self.form.model();
self.common.user = User { self.common.user.email = model.email;
id: self.common.user.id.clone(), self.common.user.display_name = model.display_name;
email: model.email, self.common.user.first_name = model.first_name;
display_name: model.display_name, self.common.user.last_name = model.last_name;
first_name: model.first_name, if let Some(avatar) = maybe_to_base64(&self.avatar)? {
last_name: model.last_name, self.common.user.avatar = avatar;
creation_date: self.common.user.creation_date, }
uuid: self.common.user.uuid.clone(),
groups: self.common.user.groups.clone(),
};
self.just_updated = true; self.just_updated = true;
} }
}; };
Ok(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<Option<String>> {
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)))
}
}
}

View File

@ -26,7 +26,11 @@ use anyhow::{Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use yew::{ use yew::{
prelude::*, prelude::*,
services::{fetch::FetchTask, ConsoleService}, services::{
fetch::FetchTask,
reader::{FileData, ReaderService, ReaderTask},
ConsoleService,
},
}; };
use yewtil::NeqAssign; use yewtil::NeqAssign;
@ -40,13 +44,34 @@ pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
fn mut_common(&mut self) -> &mut CommonComponentParts<C>; fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
} }
enum AnyTask {
None,
FetchTask(FetchTask),
ReaderTask(ReaderTask),
}
impl AnyTask {
fn is_some(&self) -> bool {
!matches!(self, AnyTask::None)
}
}
impl From<Option<FetchTask>> for AnyTask {
fn from(task: Option<FetchTask>) -> Self {
match task {
Some(t) => AnyTask::FetchTask(t),
None => AnyTask::None,
}
}
}
/// Structure that contains the common parts needed by most components. /// Structure that contains the common parts needed by most components.
/// The fields of [`props`] are directly accessible through a `Deref` implementation. /// The fields of [`props`] are directly accessible through a `Deref` implementation.
pub struct CommonComponentParts<C: CommonComponent<C>> { pub struct CommonComponentParts<C: CommonComponent<C>> {
link: ComponentLink<C>, link: ComponentLink<C>,
pub props: <C as Component>::Properties, pub props: <C as Component>::Properties,
pub error: Option<Error>, pub error: Option<Error>,
task: Option<FetchTask>, task: AnyTask,
} }
impl<C: CommonComponent<C>> CommonComponentParts<C> { impl<C: CommonComponent<C>> CommonComponentParts<C> {
@ -57,7 +82,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// Cancel any background task. /// Cancel any background task.
pub fn cancel_task(&mut self) { pub fn cancel_task(&mut self) {
self.task = None; self.task = AnyTask::None;
} }
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self { pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
@ -65,7 +90,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
link, link,
props, props,
error: None, error: None,
task: None, task: AnyTask::None,
} }
} }
@ -131,7 +156,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>, M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static, Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
{ {
self.task = Some(method(req, self.link.callback_once(callback))?); self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
Ok(()) Ok(())
} }
@ -156,7 +181,19 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
ConsoleService::log(&e.to_string()); ConsoleService::log(&e.to_string());
self.error = Some(e); self.error = Some(e);
}) })
.ok(); .ok()
.into();
}
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
where
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
{
self.task = AnyTask::ReaderTask(ReaderService::read_file(
file,
self.link.callback_once(callback),
)?);
Ok(())
} }
} }