lldap/app/src/components/user_details_form.rs
2022-11-25 15:35:48 +01:00

441 lines
15 KiB
Rust

use std::str::FromStr;
use crate::{
components::user_details::User,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use wasm_bindgen::JsCast;
use yew::{prelude::*, services::ConsoleService};
use yew_form_derive::Model;
#[derive(PartialEq, Eq, 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.
#[derive(Model, Validate, PartialEq, Eq, Clone)]
pub struct UserModel {
#[validate(email)]
email: String,
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,Eq",
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 {
common: CommonComponentParts<Self>,
form: yew_form::Form<UserModel>,
avatar: JsFile,
/// True if we just successfully updated the user, to display a success message.
just_updated: bool,
}
pub enum Msg {
/// A form field changed.
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<update_user::ResponseData>),
}
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
/// The current user details.
pub user: User,
}
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
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::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)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for UserDetailsForm {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> 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 {
common: CommonComponentParts::<Self>::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::<Self>::update(self, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
type Field = yew_form::Field<UserModel>;
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
let avatar_string = avatar_base64
.as_deref()
.or(self.common.user.avatar.as_deref())
.unwrap_or("");
html! {
<div class="py-3">
<form class="form">
<div class="form-group row mb-3">
<label for="userId"
class="form-label col-4 col-form-label">
{"User ID: "}
</label>
<div class="col-8">
<span id="userId" class="form-control-static"><i>{&self.common.user.id}</i></span>
</div>
</div>
<div class="form-group row mb-3">
<label for="creationDate"
class="form-label col-4 col-form-label">
{"Creation date: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-control-static">{&self.common.user.creation_date.naive_local().date()}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="uuid"
class="form-label col-4 col-form-label">
{"UUID: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-control-static">{&self.common.user.uuid}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="email"
class="form-label col-4 col-form-label">
{"Email"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="email"
autocomplete="email"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("email")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="display_name"
class="form-label col-4 col-form-label">
{"Display Name: "}
</label>
<div class="col-8">
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="display_name"
autocomplete="name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("display_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="first_name"
class="form-label col-4 col-form-label">
{"First Name: "}
</label>
<div class="col-8">
<Field
class="form-control"
form=&self.form
field_name="first_name"
autocomplete="given-name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("first_name")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="last_name"
class="form-label col-4 col-form-label">
{"Last Name: "}
</label>
<div class="col-8">
<Field
class="form-control"
form=&self.form
field_name="last_name"
autocomplete="family-name"
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("last_name")}
</div>
</div>
</div>
<div class="form-group row align-items-center mb-3">
<label for="avatar"
class="form-label col-4 col-form-label">
{"Avatar: "}
</label>
<div class="col-8">
<div class="row align-items-center">
<div class="col-8">
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput=self.common.callback(|_| Msg::Update) />
</div>
<div class="col-4">
<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>
</div>
</div>
<div class="form-group row justify-content-center mt-3">
<button
type="submit"
class="btn btn-primary col-auto col-form-label"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
<i class="bi-save me-2"></i>
{"Save changes"}
</button>
</div>
</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 class="alert alert-success mt-4">{"User successfully updated!"}</div>
</div>
</div>
}
}
}
impl UserDetailsForm {
fn submit_user_update_form(&mut self) -> Result<bool> {
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(),
email: None,
displayName: None,
firstName: None,
lastName: None,
avatar: 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);
}
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::<UpdateUser, _>(
req,
Msg::UserUpdated,
"Error trying to update user",
);
Ok(false)
}
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
self.common.cancel_task();
match r {
Err(e) => return Err(e),
Ok(_) => {
let model = self.form.model();
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 = Some(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<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)))
}
}
}