app: Add support for user-created attributes
Note: This PR doesn't handle errors around Jpeg files very well. Co-authored-by: Bojidar Marinov <bojidar.marinov.bg@gmail.com> Co-authored-by: Austin Alvarado <pixelrazor@gmail.com>
This commit is contained in:
committed by
nitnelave
parent
1f3f73585b
commit
dcba3d17dc
@@ -37,12 +37,16 @@ version = "0.3"
|
|||||||
features = [
|
features = [
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
|
"Event",
|
||||||
"FileReader",
|
"FileReader",
|
||||||
|
"FormData",
|
||||||
"HtmlDocument",
|
"HtmlDocument",
|
||||||
|
"HtmlFormElement",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlOptionElement",
|
"HtmlOptionElement",
|
||||||
"HtmlOptionsCollection",
|
"HtmlOptionsCollection",
|
||||||
"HtmlSelectElement",
|
"HtmlSelectElement",
|
||||||
|
"SubmitEvent",
|
||||||
"console",
|
"console",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ query GetUserAttributesSchema {
|
|||||||
isVisible
|
isVisible
|
||||||
isEditable
|
isEditable
|
||||||
isHardcoded
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,30 @@ query GetUserDetails($id: String!) {
|
|||||||
user(userId: $id) {
|
user(userId: $id) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
displayName
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
avatar
|
avatar
|
||||||
|
displayName
|
||||||
creationDate
|
creationDate
|
||||||
uuid
|
uuid
|
||||||
groups {
|
groups {
|
||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schema {
|
||||||
|
userSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
isReadonly
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use yew::{function_component, html, virtual_dom::AttrValue, Properties};
|
|||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
query_path = "queries/get_user_details.graphql",
|
query_path = "queries/get_user_details.graphql",
|
||||||
|
variables_derives = "Clone,PartialEq,Eq",
|
||||||
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||||
custom_scalars_module = "crate::infra::graphql"
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -1,22 +1,45 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
form::{field::Field, submit::Submit},
|
form::{
|
||||||
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
field::Field,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
router::AppRoute,
|
router::AppRoute,
|
||||||
},
|
},
|
||||||
|
convert_attribute_type,
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{anyhow, ensure, Result};
|
||||||
use gloo_console::log;
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use lldap_auth::{opaque, registration};
|
use lldap_auth::{opaque, registration};
|
||||||
|
use validator::validate_email;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
|
use web_sys::{FormData, HtmlFormElement};
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
|
use get_user_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||||
|
|
||||||
#[derive(GraphQLQuery)]
|
#[derive(GraphQLQuery)]
|
||||||
#[graphql(
|
#[graphql(
|
||||||
schema_path = "../schema.graphql",
|
schema_path = "../schema.graphql",
|
||||||
@@ -29,17 +52,14 @@ pub struct CreateUser;
|
|||||||
pub struct CreateUserForm {
|
pub struct CreateUserForm {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
form: yew_form::Form<CreateUserModel>,
|
form: yew_form::Form<CreateUserModel>,
|
||||||
|
attributes_schema: Option<Vec<Attribute>>,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
|
||||||
pub struct CreateUserModel {
|
pub struct CreateUserModel {
|
||||||
#[validate(length(min = 1, message = "Username is required"))]
|
#[validate(length(min = 1, message = "Username is required"))]
|
||||||
username: String,
|
username: String,
|
||||||
#[validate(email(message = "A valid email is required"))]
|
|
||||||
email: String,
|
|
||||||
display_name: String,
|
|
||||||
first_name: String,
|
|
||||||
last_name: String,
|
|
||||||
#[validate(custom(
|
#[validate(custom(
|
||||||
function = "empty_or_long",
|
function = "empty_or_long",
|
||||||
message = "Password should be longer than 8 characters (or left empty)"
|
message = "Password should be longer than 8 characters (or left empty)"
|
||||||
@@ -59,6 +79,7 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
|
|||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
Update,
|
Update,
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
SubmitForm,
|
SubmitForm,
|
||||||
CreateUserResponse(Result<create_user::ResponseData>),
|
CreateUserResponse(Result<create_user::ResponseData>),
|
||||||
SuccessfulCreation,
|
SuccessfulCreation,
|
||||||
@@ -79,21 +100,56 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes_schema =
|
||||||
|
Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
Msg::SubmitForm => {
|
Msg::SubmitForm => {
|
||||||
if !self.form.validate() {
|
ensure!(self.form.validate(), "Check the form for errors");
|
||||||
bail!("Check the form for errors");
|
|
||||||
|
let form = self.form_ref.cast::<HtmlFormElement>().unwrap();
|
||||||
|
let form_data = FormData::new_with_form(&form)
|
||||||
|
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||||
|
let all_values = get_values_from_form_data(
|
||||||
|
self.attributes_schema
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|attr| !attr.is_readonly)
|
||||||
|
.collect(),
|
||||||
|
&form_data,
|
||||||
|
)?;
|
||||||
|
{
|
||||||
|
let email_values = &all_values
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name == "mail")
|
||||||
|
.ok_or_else(|| anyhow!("Email is required"))?
|
||||||
|
.1;
|
||||||
|
ensure!(email_values.len() == 1, "Email is required");
|
||||||
|
ensure!(validate_email(&email_values[0]), "Email is not valid");
|
||||||
}
|
}
|
||||||
|
let attributes = if all_values.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, value)| !value.is_empty())
|
||||||
|
.map(|(name, value)| create_user::AttributeValueInput { name, value })
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
let to_option = |s: String| if s.is_empty() { None } else { Some(s) };
|
|
||||||
let req = create_user::Variables {
|
let req = create_user::Variables {
|
||||||
user: create_user::CreateUserInput {
|
user: create_user::CreateUserInput {
|
||||||
id: model.username,
|
id: model.username,
|
||||||
email: Some(model.email),
|
email: None,
|
||||||
displayName: to_option(model.display_name),
|
displayName: None,
|
||||||
firstName: to_option(model.first_name),
|
firstName: None,
|
||||||
lastName: to_option(model.last_name),
|
lastName: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
attributes: None,
|
attributes,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.common.call_graphql::<CreateUser, _>(
|
self.common.call_graphql::<CreateUser, _>(
|
||||||
@@ -177,11 +233,20 @@ impl Component for CreateUserForm {
|
|||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
type Properties = ();
|
type Properties = ();
|
||||||
|
|
||||||
fn create(_: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
Self {
|
let mut component = Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
|
||||||
}
|
attributes_schema: None,
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
|
};
|
||||||
|
component.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_user_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch user schema",
|
||||||
|
);
|
||||||
|
component
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
@@ -192,38 +257,22 @@ impl Component for CreateUserForm {
|
|||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form py-3" style="max-width: 636px">
|
<form class="form py-3"
|
||||||
|
ref={self.form_ref.clone()}>
|
||||||
<Field<CreateUserModel>
|
<Field<CreateUserModel>
|
||||||
form={&self.form}
|
form={&self.form}
|
||||||
required=true
|
required=true
|
||||||
label="User name"
|
label="User name"
|
||||||
field_name="username"
|
field_name="username"
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<Field<CreateUserModel>
|
{
|
||||||
form={&self.form}
|
self.attributes_schema
|
||||||
required=true
|
.iter()
|
||||||
label="Email"
|
.flatten()
|
||||||
field_name="email"
|
.filter(|a| !a.is_readonly)
|
||||||
input_type="email"
|
.map(get_custom_attribute_input)
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
.collect::<Vec<_>>()
|
||||||
<Field<CreateUserModel>
|
}
|
||||||
form={&self.form}
|
|
||||||
label="Display name"
|
|
||||||
field_name="display_name"
|
|
||||||
autocomplete="name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<CreateUserModel>
|
|
||||||
form={&self.form}
|
|
||||||
label="First name"
|
|
||||||
field_name="first_name"
|
|
||||||
autocomplete="given-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<CreateUserModel>
|
|
||||||
form={&self.form}
|
|
||||||
label="Last name"
|
|
||||||
field_name="last_name"
|
|
||||||
autocomplete="family-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<CreateUserModel>
|
<Field<CreateUserModel>
|
||||||
form={&self.form}
|
form={&self.form}
|
||||||
label="Password"
|
label="Password"
|
||||||
@@ -255,3 +304,46 @@ impl Component for CreateUserForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_custom_attribute_input(attribute_schema: &Attribute) -> Html {
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttributeValue = (String, Vec<String>);
|
||||||
|
|
||||||
|
fn get_values_from_form_data(
|
||||||
|
schema: Vec<&Attribute>,
|
||||||
|
form: &FormData,
|
||||||
|
) -> Result<Vec<AttributeValue>> {
|
||||||
|
schema
|
||||||
|
.into_iter()
|
||||||
|
.map(|attr| -> Result<AttributeValue> {
|
||||||
|
let val = form
|
||||||
|
.get_all(attr.name.as_str())
|
||||||
|
.iter()
|
||||||
|
.map(|js_val| js_val.as_string().unwrap_or_default())
|
||||||
|
.filter(|val| !val.is_empty())
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
ensure!(
|
||||||
|
val.len() <= 1 || attr.is_list,
|
||||||
|
"Multiple values supplied for non-list attribute {}",
|
||||||
|
attr.name
|
||||||
|
);
|
||||||
|
Ok((attr.name.clone(), val))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|||||||
190
app/src/components/form/attribute_input.rs
Normal file
190
app/src/components/form/attribute_input.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
use crate::{
|
||||||
|
components::form::{date_input::DateTimeInput, file_input::JpegFileInput},
|
||||||
|
infra::{schema::AttributeType, tooltip::Tooltip},
|
||||||
|
};
|
||||||
|
use web_sys::Element;
|
||||||
|
use yew::{
|
||||||
|
function_component, html, use_effect_with_deps, use_node_ref, virtual_dom::AttrValue,
|
||||||
|
Component, Context, Html, Properties,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct AttributeInputProps {
|
||||||
|
name: AttrValue,
|
||||||
|
attribute_type: AttributeType,
|
||||||
|
#[prop_or(None)]
|
||||||
|
value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AttributeInput)]
|
||||||
|
fn attribute_input(props: &AttributeInputProps) -> Html {
|
||||||
|
let input_type = match props.attribute_type {
|
||||||
|
AttributeType::String => "text",
|
||||||
|
AttributeType::Integer => "number",
|
||||||
|
AttributeType::DateTime => {
|
||||||
|
return html! {
|
||||||
|
<DateTimeInput name={props.name.clone()} value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttributeType::Jpeg => {
|
||||||
|
return html! {
|
||||||
|
<JpegFileInput name={props.name.clone()} value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<input
|
||||||
|
type={input_type}
|
||||||
|
name={props.name.clone()}
|
||||||
|
class="form-control"
|
||||||
|
value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct AttributeLabelProps {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
#[function_component(AttributeLabel)]
|
||||||
|
fn attribute_label(props: &AttributeLabelProps) -> Html {
|
||||||
|
let tooltip_ref = use_node_ref();
|
||||||
|
|
||||||
|
use_effect_with_deps(
|
||||||
|
move |tooltip_ref| {
|
||||||
|
Tooltip::new(
|
||||||
|
tooltip_ref
|
||||||
|
.cast::<Element>()
|
||||||
|
.expect("Tooltip element should exist"),
|
||||||
|
);
|
||||||
|
|| {}
|
||||||
|
},
|
||||||
|
tooltip_ref.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<label for={props.name.clone()}
|
||||||
|
class="form-label col-4 col-form-label"
|
||||||
|
>
|
||||||
|
{props.name[0..1].to_uppercase() + &props.name[1..].replace('_', " ")}{":"}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-link"
|
||||||
|
type="button"
|
||||||
|
data-bs-placement="right"
|
||||||
|
title={props.name.clone()}
|
||||||
|
ref={tooltip_ref}>
|
||||||
|
<i class="bi bi-info-circle" aria-label="Info" />
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct SingleAttributeInputProps {
|
||||||
|
pub name: String,
|
||||||
|
pub attribute_type: AttributeType,
|
||||||
|
#[prop_or(None)]
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(SingleAttributeInput)]
|
||||||
|
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<AttributeLabel name={props.name.clone()} />
|
||||||
|
<div class="col-8">
|
||||||
|
<AttributeInput
|
||||||
|
attribute_type={props.attribute_type.clone()}
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={props.value.clone()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ListAttributeInputProps {
|
||||||
|
pub name: String,
|
||||||
|
pub attribute_type: AttributeType,
|
||||||
|
#[prop_or(vec!())]
|
||||||
|
pub values: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ListAttributeInputMsg {
|
||||||
|
Remove(usize),
|
||||||
|
Append,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListAttributeInput {
|
||||||
|
indices: Vec<usize>,
|
||||||
|
next_index: usize,
|
||||||
|
values: Vec<String>,
|
||||||
|
}
|
||||||
|
impl Component for ListAttributeInput {
|
||||||
|
type Message = ListAttributeInputMsg;
|
||||||
|
type Properties = ListAttributeInputProps;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let values = ctx.props().values.clone();
|
||||||
|
Self {
|
||||||
|
indices: (0..values.len()).collect(),
|
||||||
|
next_index: values.len(),
|
||||||
|
values,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
match msg {
|
||||||
|
ListAttributeInputMsg::Remove(removed) => {
|
||||||
|
self.indices.retain_mut(|x| *x != removed);
|
||||||
|
}
|
||||||
|
ListAttributeInputMsg::Append => {
|
||||||
|
self.indices.push(self.next_index);
|
||||||
|
self.next_index += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||||
|
if ctx.props().values != self.values {
|
||||||
|
self.values.clone_from(&ctx.props().values);
|
||||||
|
self.indices = (0..self.values.len()).collect();
|
||||||
|
self.next_index = self.values.len();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let props = &ctx.props();
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<AttributeLabel name={props.name.clone()} />
|
||||||
|
<div class="col-8">
|
||||||
|
{self.indices.iter().map(|&i| html! {
|
||||||
|
<div class="input-group mb-2" key={i}>
|
||||||
|
<AttributeInput
|
||||||
|
attribute_type={props.attribute_type.clone()}
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={props.values.get(i).cloned().unwrap_or_default()} />
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(move |_| ListAttributeInputMsg::Remove(i))}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Remove value" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}).collect::<Html>()}
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| ListAttributeInputMsg::Append)}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Add value"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/src/components/form/date_input.rs
Normal file
49
app/src/components/form/date_input.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::{function_component, html, use_state, virtual_dom::AttrValue, Event, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct DateTimeInputProps {
|
||||||
|
pub name: AttrValue,
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DateTimeInput)]
|
||||||
|
pub fn date_time_input(props: &DateTimeInputProps) -> Html {
|
||||||
|
let value = use_state(|| {
|
||||||
|
props
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| DateTime::<Utc>::from_str(x).ok())
|
||||||
|
});
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={value.as_ref().map(|v: &DateTime<Utc>| v.to_rfc3339())} />
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
step="1"
|
||||||
|
class="form-control"
|
||||||
|
value={value.as_ref().map(|v: &DateTime<Utc>| v.naive_utc().to_string())}
|
||||||
|
onchange={move |e: Event| {
|
||||||
|
let string_val =
|
||||||
|
e.target()
|
||||||
|
.expect("Event should have target")
|
||||||
|
.unchecked_into::<HtmlInputElement>()
|
||||||
|
.value();
|
||||||
|
value.set(
|
||||||
|
NaiveDateTime::from_str(&string_val)
|
||||||
|
.ok()
|
||||||
|
.map(|x| DateTime::from_utc(x, Utc))
|
||||||
|
)
|
||||||
|
}} />
|
||||||
|
<span class="input-group-text">{"UTC"}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
238
app/src/components/form/file_input.rs
Normal file
238
app/src/components/form/file_input.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
|
||||||
|
use anyhow::{bail, Error, Ok, Result};
|
||||||
|
use gloo_file::{
|
||||||
|
callbacks::{read_as_bytes, FileReader},
|
||||||
|
File,
|
||||||
|
};
|
||||||
|
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
||||||
|
use yew::Properties;
|
||||||
|
use yew::{prelude::*, virtual_dom::AttrValue};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct JsFile {
|
||||||
|
file: Option<File>,
|
||||||
|
contents: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for JsFile {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
self.file.as_ref().map(File::name).unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_base64(file: &JsFile) -> Result<String> {
|
||||||
|
match file {
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: None,
|
||||||
|
} => Ok(String::new()),
|
||||||
|
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(base64::encode(data))
|
||||||
|
}
|
||||||
|
JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: Some(data),
|
||||||
|
} => Ok(base64::encode(data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [yew::Component] to display the user details, with a form allowing to edit them.
|
||||||
|
pub struct JpegFileInput {
|
||||||
|
// None means that the avatar hasn't changed.
|
||||||
|
avatar: Option<JsFile>,
|
||||||
|
reader: Option<FileReader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
Update,
|
||||||
|
/// A new file was selected.
|
||||||
|
FileSelected(File),
|
||||||
|
/// The "Clear" button for the avatar was clicked.
|
||||||
|
ClearClicked,
|
||||||
|
/// A picked file finished loading.
|
||||||
|
FileLoaded(String, Result<Vec<u8>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub name: AttrValue,
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for JpegFileInput {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
avatar: Some(JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: ctx
|
||||||
|
.props()
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| base64::decode(x).ok()),
|
||||||
|
}),
|
||||||
|
reader: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed(&mut self, ctx: &Context<Self>) -> bool {
|
||||||
|
self.avatar = Some(JsFile {
|
||||||
|
file: None,
|
||||||
|
contents: ctx
|
||||||
|
.props()
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|x| base64::decode(x).ok()),
|
||||||
|
});
|
||||||
|
self.reader = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => true,
|
||||||
|
Msg::FileSelected(new_avatar) => {
|
||||||
|
if self
|
||||||
|
.avatar
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
||||||
|
!= Some(new_avatar.name())
|
||||||
|
{
|
||||||
|
let file_name = new_avatar.name();
|
||||||
|
let link = ctx.link().clone();
|
||||||
|
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
||||||
|
link.send_message(Msg::FileLoaded(
|
||||||
|
file_name,
|
||||||
|
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
||||||
|
))
|
||||||
|
}));
|
||||||
|
self.avatar = Some(JsFile {
|
||||||
|
file: Some(new_avatar),
|
||||||
|
contents: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Msg::ClearClicked => {
|
||||||
|
self.avatar = Some(JsFile::default());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Msg::FileLoaded(file_name, data) => {
|
||||||
|
if let Some(avatar) = &mut self.avatar {
|
||||||
|
if let Some(file) = &avatar.file {
|
||||||
|
if file.name() == file_name {
|
||||||
|
if let Result::Ok(data) = data {
|
||||||
|
if !is_valid_jpeg(data.as_slice()) {
|
||||||
|
// Clear the selection.
|
||||||
|
self.avatar = Some(JsFile::default());
|
||||||
|
// TODO: bail!("Chosen image is not a valid JPEG");
|
||||||
|
} else {
|
||||||
|
avatar.contents = Some(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reader = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
|
||||||
|
let avatar_string = match &self.avatar {
|
||||||
|
Some(avatar) => {
|
||||||
|
let avatar_base64 = to_base64(avatar);
|
||||||
|
avatar_base64.as_deref().unwrap_or("").to_owned()
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-5">
|
||||||
|
<input type="hidden" name={ctx.props().name.clone()} value={avatar_string.clone()} />
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatarInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg"
|
||||||
|
oninput={link.callback(|e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
Self::upload_files(input.files())
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary col-auto"
|
||||||
|
id="avatarClear"
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| {Msg::ClearClicked})}>
|
||||||
|
{"Clear"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
{
|
||||||
|
if !avatar_string.is_empty() {
|
||||||
|
html!{
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
||||||
|
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
||||||
|
alt="Avatar" />
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JpegFileInput {
|
||||||
|
fn upload_files(files: Option<FileList>) -> Msg {
|
||||||
|
match files {
|
||||||
|
Some(files) if files.length() > 0 => {
|
||||||
|
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
||||||
|
}
|
||||||
|
Some(_) | None => Msg::Update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
||||||
|
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
||||||
|
.decode()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
pub mod attribute_input;
|
||||||
pub mod checkbox;
|
pub mod checkbox;
|
||||||
|
pub mod date_input;
|
||||||
pub mod field;
|
pub mod field;
|
||||||
|
pub mod file_input;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod static_value;
|
pub mod static_value;
|
||||||
pub mod submit;
|
pub mod submit;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::{
|
|||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
},
|
||||||
|
convert_attribute_type,
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
@@ -22,12 +23,23 @@ pub struct GetUserDetails;
|
|||||||
|
|
||||||
pub type User = get_user_details::GetUserDetailsUser;
|
pub type User = get_user_details::GetUserDetailsUser;
|
||||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||||
|
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||||
|
pub type AttributeSchema = get_user_details::GetUserDetailsSchemaUserSchemaAttributes;
|
||||||
|
pub type AttributeType = get_user_details::AttributeType;
|
||||||
|
|
||||||
|
convert_attribute_type!(AttributeType);
|
||||||
|
|
||||||
pub struct UserDetails {
|
pub struct UserDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
|
||||||
/// received the server response yet.
|
/// received the server response yet.
|
||||||
user: Option<User>,
|
user_and_schema: Option<(User, Vec<AttributeSchema>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserDetails {
|
||||||
|
fn mut_groups(&mut self) -> &mut Vec<Group> {
|
||||||
|
&mut self.user_and_schema.as_mut().unwrap().0.groups
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State machine describing the possible transitions of the component state.
|
/// State machine describing the possible transitions of the component state.
|
||||||
@@ -50,22 +62,20 @@ impl CommonComponent<UserDetails> for UserDetails {
|
|||||||
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::UserDetailsResponse(response) => match response {
|
Msg::UserDetailsResponse(response) => match response {
|
||||||
Ok(user) => self.user = Some(user.user),
|
Ok(user) => {
|
||||||
|
self.user_and_schema = Some((user.user, user.schema.user_schema.attributes))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.user = None;
|
self.user_and_schema = None;
|
||||||
bail!("Error getting user details: {}", e);
|
bail!("Error getting user details: {}", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Msg::OnError(e) => return Err(e),
|
Msg::OnError(e) => return Err(e),
|
||||||
Msg::OnUserAddedToGroup(group) => {
|
Msg::OnUserAddedToGroup(group) => {
|
||||||
self.user.as_mut().unwrap().groups.push(group);
|
self.mut_groups().push(group);
|
||||||
}
|
}
|
||||||
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
Msg::OnUserRemovedFromGroup((_, group_id)) => {
|
||||||
self.user
|
self.mut_groups().retain(|g| g.id != group_id);
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.groups
|
|
||||||
.retain(|g| g.id != group_id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -178,7 +188,7 @@ impl Component for UserDetails {
|
|||||||
fn create(ctx: &Context<Self>) -> Self {
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
let mut table = Self {
|
let mut table = Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
user: None,
|
user_and_schema: None,
|
||||||
};
|
};
|
||||||
table.get_user_details(ctx);
|
table.get_user_details(ctx);
|
||||||
table
|
table
|
||||||
@@ -189,10 +199,8 @@ impl Component for UserDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
match (&self.user, &self.common.error) {
|
match (&self.user_and_schema, &self.common.error) {
|
||||||
(None, None) => html! {{"Loading..."}},
|
(Some((u, schema)), error) => {
|
||||||
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
|
||||||
(Some(u), error) => {
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h3>{u.id.to_string()}</h3>
|
<h3>{u.id.to_string()}</h3>
|
||||||
@@ -207,13 +215,19 @@ impl Component for UserDetails {
|
|||||||
<div>
|
<div>
|
||||||
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
<h5 class="row m-3 fw-bold">{"User details"}</h5>
|
||||||
</div>
|
</div>
|
||||||
<UserDetailsForm user={u.clone()} />
|
<UserDetailsForm
|
||||||
|
user={u.clone()}
|
||||||
|
user_attributes_schema={schema.clone()}
|
||||||
|
is_admin={ctx.props().is_admin}
|
||||||
|
/>
|
||||||
{self.view_group_memberships(ctx, u)}
|
{self.view_group_memberships(ctx, u)}
|
||||||
{self.view_add_group_button(ctx, u)}
|
{self.view_add_group_button(ctx, u)}
|
||||||
{self.view_messages(error)}
|
{self.view_messages(error)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(None, None) => html! {{"Loading..."}},
|
||||||
|
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,32 @@
|
|||||||
use std::{fmt::Display, str::FromStr};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
form::{field::Field, static_value::StaticValue, submit::Submit},
|
form::{
|
||||||
user_details::User,
|
attribute_input::{ListAttributeInput, SingleAttributeInput},
|
||||||
|
static_value::StaticValue,
|
||||||
|
submit::Submit,
|
||||||
|
},
|
||||||
|
user_details::{Attribute, AttributeSchema, User},
|
||||||
|
},
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
},
|
},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
|
||||||
};
|
|
||||||
use anyhow::{bail, Error, Result};
|
|
||||||
use gloo_file::{
|
|
||||||
callbacks::{read_as_bytes, FileReader},
|
|
||||||
File,
|
|
||||||
};
|
};
|
||||||
|
use anyhow::{anyhow, bail, ensure, Ok, Result};
|
||||||
|
use gloo_console::log;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator::HasLen;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
use web_sys::{FormData, HtmlFormElement};
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct JsFile {
|
|
||||||
file: Option<File>,
|
|
||||||
contents: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for JsFile {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{}",
|
|
||||||
self.file.as_ref().map(File::name).unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, Eq, Clone)]
|
#[derive(Model, Validate, PartialEq, Eq, Clone)]
|
||||||
pub struct UserModel {
|
pub struct UserModel {
|
||||||
#[validate(email)]
|
#[validate(email)]
|
||||||
email: String,
|
email: String,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
first_name: String,
|
|
||||||
last_name: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The GraphQL query sent to the server to update the user details.
|
/// The GraphQL query sent to the server to update the user details.
|
||||||
@@ -71,25 +44,17 @@ 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>,
|
||||||
// None means that the avatar hasn't changed.
|
|
||||||
avatar: Option<JsFile>,
|
|
||||||
reader: Option<FileReader>,
|
|
||||||
/// 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,
|
||||||
user: User,
|
user: User,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
/// A form field changed.
|
/// A form field changed.
|
||||||
Update,
|
Update,
|
||||||
/// A new file was selected.
|
|
||||||
FileSelected(File),
|
|
||||||
/// The "Submit" button was clicked.
|
/// The "Submit" button was clicked.
|
||||||
SubmitClicked,
|
SubmitClicked,
|
||||||
/// The "Clear" button for the avatar was clicked.
|
|
||||||
ClearAvatarClicked,
|
|
||||||
/// A picked file finished loading.
|
|
||||||
FileLoaded(String, Result<Vec<u8>>),
|
|
||||||
/// 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>),
|
||||||
}
|
}
|
||||||
@@ -98,6 +63,8 @@ pub enum Msg {
|
|||||||
pub struct Props {
|
pub struct Props {
|
||||||
/// The current user details.
|
/// The current user details.
|
||||||
pub user: User,
|
pub user: User,
|
||||||
|
pub user_attributes_schema: Vec<AttributeSchema>,
|
||||||
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
||||||
@@ -108,53 +75,8 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
match msg {
|
match msg {
|
||||||
Msg::Update => Ok(true),
|
Msg::Update => Ok(true),
|
||||||
Msg::FileSelected(new_avatar) => {
|
|
||||||
if self
|
|
||||||
.avatar
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|f| f.file.as_ref().map(|f| f.name()))
|
|
||||||
!= Some(new_avatar.name())
|
|
||||||
{
|
|
||||||
let file_name = new_avatar.name();
|
|
||||||
let link = ctx.link().clone();
|
|
||||||
self.reader = Some(read_as_bytes(&new_avatar, move |res| {
|
|
||||||
link.send_message(Msg::FileLoaded(
|
|
||||||
file_name,
|
|
||||||
res.map_err(|e| anyhow::anyhow!("{:#}", e)),
|
|
||||||
))
|
|
||||||
}));
|
|
||||||
self.avatar = Some(JsFile {
|
|
||||||
file: Some(new_avatar),
|
|
||||||
contents: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
Msg::SubmitClicked => self.submit_user_update_form(ctx),
|
||||||
Msg::ClearAvatarClicked => {
|
|
||||||
self.avatar = Some(JsFile::default());
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
Msg::UserUpdated(response) => self.user_update_finished(response),
|
Msg::UserUpdated(response) => self.user_update_finished(response),
|
||||||
Msg::FileLoaded(file_name, data) => {
|
|
||||||
if let Some(avatar) = &mut self.avatar {
|
|
||||||
if let Some(file) = &avatar.file {
|
|
||||||
if file.name() == file_name {
|
|
||||||
let data = data?;
|
|
||||||
if !is_valid_jpeg(data.as_slice()) {
|
|
||||||
// Clear the selection.
|
|
||||||
self.avatar = None;
|
|
||||||
bail!("Chosen image is not a valid JPEG");
|
|
||||||
} else {
|
|
||||||
avatar.contents = Some(data);
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.reader = None;
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,16 +93,13 @@ impl Component for UserDetailsForm {
|
|||||||
let model = UserModel {
|
let model = UserModel {
|
||||||
email: ctx.props().user.email.clone(),
|
email: ctx.props().user.email.clone(),
|
||||||
display_name: ctx.props().user.display_name.clone(),
|
display_name: ctx.props().user.display_name.clone(),
|
||||||
first_name: ctx.props().user.first_name.clone(),
|
|
||||||
last_name: ctx.props().user.last_name.clone(),
|
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
common: CommonComponentParts::<Self>::create(),
|
common: CommonComponentParts::<Self>::create(),
|
||||||
form: yew_form::Form::new(model),
|
form: yew_form::Form::new(model),
|
||||||
avatar: None,
|
|
||||||
just_updated: false,
|
just_updated: false,
|
||||||
reader: None,
|
|
||||||
user: ctx.props().user.clone(),
|
user: ctx.props().user.clone(),
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,93 +111,41 @@ impl Component for UserDetailsForm {
|
|||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
|
|
||||||
let avatar_string = match &self.avatar {
|
let can_edit =
|
||||||
Some(avatar) => {
|
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
|
||||||
let avatar_base64 = to_base64(avatar);
|
let display_field = |a: &AttributeSchema| {
|
||||||
avatar_base64.as_deref().unwrap_or("").to_owned()
|
if can_edit(a) {
|
||||||
|
get_custom_attribute_input(a, &self.user.attributes)
|
||||||
|
} else {
|
||||||
|
get_custom_attribute_static(a, &self.user.attributes)
|
||||||
}
|
}
|
||||||
None => self.user.avatar.as_deref().unwrap_or("").to_owned(),
|
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<form class="form">
|
<form
|
||||||
|
class="form"
|
||||||
|
ref={self.form_ref.clone()}>
|
||||||
<StaticValue label="User ID" id="userId">
|
<StaticValue label="User ID" id="userId">
|
||||||
<i>{&self.user.id}</i>
|
<i>{&self.user.id}</i>
|
||||||
</StaticValue>
|
</StaticValue>
|
||||||
<StaticValue label="Creation date" id="creationDate">
|
{
|
||||||
{&self.user.creation_date.naive_local().date()}
|
ctx
|
||||||
</StaticValue>
|
.props()
|
||||||
<StaticValue label="UUID" id="uuid">
|
.user_attributes_schema
|
||||||
{&self.user.uuid}
|
.iter()
|
||||||
</StaticValue>
|
.filter(|a| a.is_hardcoded && a.name != "user_id")
|
||||||
<Field<UserModel>
|
.map(display_field)
|
||||||
form={&self.form}
|
.collect::<Vec<_>>()
|
||||||
required=true
|
}
|
||||||
label="Email"
|
{
|
||||||
field_name="email"
|
ctx
|
||||||
input_type="email"
|
.props()
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
.user_attributes_schema
|
||||||
<Field<UserModel>
|
.iter()
|
||||||
form={&self.form}
|
.filter(|a| !a.is_hardcoded)
|
||||||
label="Display name"
|
.map(display_field)
|
||||||
field_name="display_name"
|
.collect::<Vec<_>>()
|
||||||
autocomplete="name"
|
}
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<UserModel>
|
|
||||||
form={&self.form}
|
|
||||||
label="First name"
|
|
||||||
field_name="first_name"
|
|
||||||
autocomplete="given-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<Field<UserModel>
|
|
||||||
form={&self.form}
|
|
||||||
label="Last name"
|
|
||||||
field_name="last_name"
|
|
||||||
autocomplete="family-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<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-5">
|
|
||||||
<input
|
|
||||||
class="form-control"
|
|
||||||
id="avatarInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg"
|
|
||||||
oninput={link.callback(|e: InputEvent| {
|
|
||||||
let input: HtmlInputElement = e.target_unchecked_into();
|
|
||||||
Self::upload_files(input.files())
|
|
||||||
})} />
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary col-auto"
|
|
||||||
id="avatarClear"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::ClearAvatarClicked})}>
|
|
||||||
{"Clear"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
{
|
|
||||||
if !avatar_string.is_empty() {
|
|
||||||
html!{
|
|
||||||
<img
|
|
||||||
id="avatarDisplay"
|
|
||||||
src={format!("data:image/jpeg;base64, {}", avatar_string)}
|
|
||||||
style="max-height:128px;max-width:128px;height:auto;width:auto;"
|
|
||||||
alt="Avatar" />
|
|
||||||
}
|
|
||||||
} else { html! {} }
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Submit
|
<Submit
|
||||||
text="Save changes"
|
text="Save changes"
|
||||||
disabled={self.common.is_task_running()}
|
disabled={self.common.is_task_running()}
|
||||||
@@ -301,19 +168,136 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AttributeValue = (String, Vec<String>);
|
||||||
|
|
||||||
|
fn get_values_from_form_data(
|
||||||
|
schema: Vec<&AttributeSchema>,
|
||||||
|
form: &FormData,
|
||||||
|
) -> Result<Vec<AttributeValue>> {
|
||||||
|
schema
|
||||||
|
.into_iter()
|
||||||
|
.map(|attr| -> Result<AttributeValue> {
|
||||||
|
let val = form
|
||||||
|
.get_all(attr.name.as_str())
|
||||||
|
.iter()
|
||||||
|
.map(|js_val| js_val.as_string().unwrap_or_default())
|
||||||
|
.filter(|val| !val.is_empty())
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
ensure!(
|
||||||
|
val.length() <= 1 || attr.is_list,
|
||||||
|
"Multiple values supplied for non-list attribute {}",
|
||||||
|
attr.name
|
||||||
|
);
|
||||||
|
Ok((attr.name.clone(), val))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
|
user_attributes: &[Attribute],
|
||||||
|
) -> Html {
|
||||||
|
if attribute_schema.is_list {
|
||||||
|
let values = user_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.map(|attribute| attribute.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
html! {
|
||||||
|
<ListAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let value = user_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.and_then(|attribute| attribute.value.first().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
html! {
|
||||||
|
<SingleAttributeInput
|
||||||
|
name={attribute_schema.name.clone()}
|
||||||
|
attribute_type={Into::<AttributeType>::into(attribute_schema.attribute_type.clone())}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_static(
|
||||||
|
attribute_schema: &AttributeSchema,
|
||||||
|
user_attributes: &[Attribute],
|
||||||
|
) -> Html {
|
||||||
|
let values = user_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == attribute_schema.name)
|
||||||
|
.map(|attribute| attribute.value.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
html! {
|
||||||
|
<StaticValue label={attribute_schema.name.clone()} id={attribute_schema.name.clone()}>
|
||||||
|
{values.into_iter().map(|x| html!{<div>{x}</div>}).collect::<Vec<_>>()}
|
||||||
|
</StaticValue>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl UserDetailsForm {
|
impl UserDetailsForm {
|
||||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
bail!("Invalid inputs");
|
bail!("Invalid inputs");
|
||||||
}
|
}
|
||||||
if let Some(JsFile {
|
// TODO: Handle unloaded files.
|
||||||
file: Some(_),
|
// if let Some(JsFile {
|
||||||
contents: None,
|
// file: Some(_),
|
||||||
}) = &self.avatar
|
// contents: None,
|
||||||
{
|
// }) = &self.avatar
|
||||||
bail!("Image file hasn't finished loading, try again");
|
// {
|
||||||
}
|
// bail!("Image file hasn't finished loading, try again");
|
||||||
let base_user = &self.user;
|
// }
|
||||||
|
let form = self.form_ref.cast::<HtmlFormElement>().unwrap();
|
||||||
|
let form_data = FormData::new_with_form(&form)
|
||||||
|
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||||
|
let mut all_values = get_values_from_form_data(
|
||||||
|
ctx.props()
|
||||||
|
.user_attributes_schema
|
||||||
|
.iter()
|
||||||
|
.filter(|attr| (ctx.props().is_admin && !attr.is_readonly) || attr.is_editable)
|
||||||
|
.collect(),
|
||||||
|
&form_data,
|
||||||
|
)?;
|
||||||
|
let base_attributes = &self.user.attributes;
|
||||||
|
log!(format!(
|
||||||
|
"base_attributes: {:#?}\nall_values: {:#?}",
|
||||||
|
base_attributes, all_values
|
||||||
|
));
|
||||||
|
all_values.retain(|(name, val)| {
|
||||||
|
let name = name.clone();
|
||||||
|
let base_val = base_attributes
|
||||||
|
.iter()
|
||||||
|
.find(|base_val| base_val.name == name);
|
||||||
|
let new_values = val.clone();
|
||||||
|
base_val
|
||||||
|
.map(|v| v.value != new_values)
|
||||||
|
.unwrap_or(!new_values.is_empty())
|
||||||
|
});
|
||||||
|
let remove_attributes: Option<Vec<String>> = if all_values.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_values.iter().map(|(name, _)| name.clone()).collect())
|
||||||
|
};
|
||||||
|
let insert_attributes: Option<Vec<update_user::AttributeValueInput>> =
|
||||||
|
if remove_attributes.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
all_values
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, value)| !value.is_empty())
|
||||||
|
.map(|(name, value)| update_user::AttributeValueInput { name, value })
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
};
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.user.id.clone(),
|
id: self.user.id.clone(),
|
||||||
email: None,
|
email: None,
|
||||||
@@ -325,23 +309,8 @@ impl UserDetailsForm {
|
|||||||
insertAttributes: None,
|
insertAttributes: None,
|
||||||
};
|
};
|
||||||
let default_user_input = user_input.clone();
|
let default_user_input = user_input.clone();
|
||||||
let model = self.form.model();
|
user_input.removeAttributes = remove_attributes;
|
||||||
let email = model.email;
|
user_input.insertAttributes = insert_attributes;
|
||||||
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);
|
|
||||||
}
|
|
||||||
if let Some(avatar) = &self.avatar {
|
|
||||||
user_input.avatar = Some(to_base64(avatar)?);
|
|
||||||
}
|
|
||||||
// Nothing changed.
|
// Nothing changed.
|
||||||
if user_input == default_user_input {
|
if user_input == default_user_input {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -361,52 +330,7 @@ impl UserDetailsForm {
|
|||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
self.user.email = model.email;
|
self.user.email = model.email;
|
||||||
self.user.display_name = model.display_name;
|
self.user.display_name = model.display_name;
|
||||||
self.user.first_name = model.first_name;
|
|
||||||
self.user.last_name = model.last_name;
|
|
||||||
if let Some(avatar) = &self.avatar {
|
|
||||||
self.user.avatar = Some(to_base64(avatar)?);
|
|
||||||
}
|
|
||||||
self.just_updated = true;
|
self.just_updated = true;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upload_files(files: Option<FileList>) -> Msg {
|
|
||||||
if let Some(files) = files {
|
|
||||||
if files.length() > 0 {
|
|
||||||
Msg::FileSelected(File::from(files.item(0).unwrap()))
|
|
||||||
} else {
|
|
||||||
Msg::Update
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Msg::Update
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_valid_jpeg(bytes: &[u8]) -> bool {
|
|
||||||
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
|
|
||||||
.decode()
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_base64(file: &JsFile) -> Result<String> {
|
|
||||||
match file {
|
|
||||||
JsFile {
|
|
||||||
file: None,
|
|
||||||
contents: _,
|
|
||||||
} => Ok(String::new()),
|
|
||||||
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(base64::encode(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::infra::api::HostService;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
use yew::{use_effect, use_state_eq, UseStateHandle};
|
use yew::{use_effect_with_deps, use_state_eq, UseStateHandle};
|
||||||
|
|
||||||
// Enum to represent a result that is fetched asynchronously.
|
// Enum to represent a result that is fetched asynchronously.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -31,22 +31,29 @@ pub fn use_graphql_call<QueryType>(
|
|||||||
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
||||||
where
|
where
|
||||||
QueryType: GraphQLQuery + 'static,
|
QueryType: GraphQLQuery + 'static,
|
||||||
|
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
|
||||||
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
||||||
{
|
{
|
||||||
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||||
use_state_eq(|| LoadableResult::Loading);
|
use_state_eq(|| LoadableResult::Loading);
|
||||||
{
|
{
|
||||||
let loadable_result = loadable_result.clone();
|
let loadable_result = loadable_result.clone();
|
||||||
use_effect(move || {
|
use_effect_with_deps(
|
||||||
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
|
move |variables| {
|
||||||
|
let task = HostService::graphql_query::<QueryType>(
|
||||||
|
variables.clone(),
|
||||||
|
"Failed graphql query",
|
||||||
|
);
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let response = task.await;
|
let response = task.await;
|
||||||
loadable_result.set(LoadableResult::Loaded(response));
|
loadable_result.set(LoadableResult::Loaded(response));
|
||||||
});
|
});
|
||||||
|
|
||||||
|| ()
|
|| ()
|
||||||
})
|
},
|
||||||
|
variables,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
loadable_result.clone()
|
loadable_result.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ pub mod functional;
|
|||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod tooltip;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use anyhow::Result;
|
|||||||
use std::{fmt::Display, str::FromStr};
|
use std::{fmt::Display, str::FromStr};
|
||||||
use validator::ValidationError;
|
use validator::ValidationError;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum AttributeType {
|
pub enum AttributeType {
|
||||||
String,
|
String,
|
||||||
Integer,
|
Integer,
|
||||||
@@ -34,25 +34,25 @@ impl FromStr for AttributeType {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! convert_attribute_type {
|
macro_rules! convert_attribute_type {
|
||||||
($source_type:ty) => {
|
($source_type:ty) => {
|
||||||
impl From<$source_type> for AttributeType {
|
impl From<$source_type> for $crate::infra::schema::AttributeType {
|
||||||
fn from(value: $source_type) -> Self {
|
fn from(value: $source_type) -> Self {
|
||||||
match value {
|
match value {
|
||||||
<$source_type>::STRING => AttributeType::String,
|
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
|
||||||
<$source_type>::INTEGER => AttributeType::Integer,
|
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
|
||||||
<$source_type>::DATE_TIME => AttributeType::DateTime,
|
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
|
||||||
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
|
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
|
||||||
_ => panic!("Unknown attribute type"),
|
_ => panic!("Unknown attribute type"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AttributeType> for $source_type {
|
impl From<$crate::infra::schema::AttributeType> for $source_type {
|
||||||
fn from(value: AttributeType) -> Self {
|
fn from(value: $crate::infra::schema::AttributeType) -> Self {
|
||||||
match value {
|
match value {
|
||||||
AttributeType::String => <$source_type>::STRING,
|
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
|
||||||
AttributeType::Integer => <$source_type>::INTEGER,
|
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
|
||||||
AttributeType::DateTime => <$source_type>::DATE_TIME,
|
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||||
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/src/infra/tooltip.rs
Normal file
12
app/src/infra/tooltip.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#![allow(clippy::empty_docs)]
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = bootstrap)]
|
||||||
|
pub type Tooltip;
|
||||||
|
|
||||||
|
#[wasm_bindgen(constructor, js_namespace = bootstrap)]
|
||||||
|
pub fn new(e: web_sys::Element) -> Tooltip;
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ use crate::{
|
|||||||
ldap::utils::{map_user_field, UserFieldType},
|
ldap::utils::{map_user_field, UserFieldType},
|
||||||
model::UserColumn,
|
model::UserColumn,
|
||||||
schema::PublicSchema,
|
schema::PublicSchema,
|
||||||
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, UserId},
|
types::{
|
||||||
|
AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, Serialized, UserId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
infra::{
|
infra::{
|
||||||
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
|
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
|
||||||
@@ -16,7 +18,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::Context as AnyhowContext;
|
use anyhow::Context as AnyhowContext;
|
||||||
use chrono::{NaiveDateTime, TimeZone};
|
use chrono::{NaiveDateTime, TimeZone};
|
||||||
use juniper::{graphql_object, FieldError, FieldResult, GraphQLInputObject};
|
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, debug_span, Instrument, Span};
|
use tracing::{debug, debug_span, Instrument, Span};
|
||||||
|
|
||||||
@@ -247,15 +249,10 @@ pub struct User<Handler: BackendHandler> {
|
|||||||
|
|
||||||
impl<Handler: BackendHandler> User<Handler> {
|
impl<Handler: BackendHandler> User<Handler> {
|
||||||
pub fn from_user(mut user: DomainUser, schema: Arc<PublicSchema>) -> FieldResult<Self> {
|
pub fn from_user(mut user: DomainUser, schema: Arc<PublicSchema>) -> FieldResult<Self> {
|
||||||
let attributes = std::mem::take(&mut user.attributes);
|
let attributes = AttributeValue::<Handler>::user_attributes_from_schema(&mut user, &schema);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
user,
|
user,
|
||||||
attributes: attributes
|
attributes,
|
||||||
.into_iter()
|
|
||||||
.map(|a| {
|
|
||||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
|
|
||||||
})
|
|
||||||
.collect::<FieldResult<Vec<_>>>()?,
|
|
||||||
schema,
|
schema,
|
||||||
groups: None,
|
groups: None,
|
||||||
_phantom: std::marker::PhantomData,
|
_phantom: std::marker::PhantomData,
|
||||||
@@ -370,42 +367,36 @@ pub struct Group<Handler: BackendHandler> {
|
|||||||
|
|
||||||
impl<Handler: BackendHandler> Group<Handler> {
|
impl<Handler: BackendHandler> Group<Handler> {
|
||||||
pub fn from_group(
|
pub fn from_group(
|
||||||
group: DomainGroup,
|
mut group: DomainGroup,
|
||||||
schema: Arc<PublicSchema>,
|
schema: Arc<PublicSchema>,
|
||||||
) -> FieldResult<Group<Handler>> {
|
) -> FieldResult<Group<Handler>> {
|
||||||
|
let attributes =
|
||||||
|
AttributeValue::<Handler>::group_attributes_from_schema(&mut group, &schema);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
group_id: group.id.0,
|
group_id: group.id.0,
|
||||||
display_name: group.display_name.to_string(),
|
display_name: group.display_name.to_string(),
|
||||||
creation_date: group.creation_date,
|
creation_date: group.creation_date,
|
||||||
uuid: group.uuid.into_string(),
|
uuid: group.uuid.into_string(),
|
||||||
attributes: group
|
attributes,
|
||||||
.attributes
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| {
|
|
||||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
|
||||||
})
|
|
||||||
.collect::<FieldResult<Vec<_>>>()?,
|
|
||||||
schema,
|
schema,
|
||||||
_phantom: std::marker::PhantomData,
|
_phantom: std::marker::PhantomData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_group_details(
|
pub fn from_group_details(
|
||||||
group_details: GroupDetails,
|
mut group_details: GroupDetails,
|
||||||
schema: Arc<PublicSchema>,
|
schema: Arc<PublicSchema>,
|
||||||
) -> FieldResult<Group<Handler>> {
|
) -> FieldResult<Group<Handler>> {
|
||||||
|
let attributes = AttributeValue::<Handler>::group_details_attributes_from_schema(
|
||||||
|
&mut group_details,
|
||||||
|
&schema,
|
||||||
|
);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
group_id: group_details.group_id.0,
|
group_id: group_details.group_id.0,
|
||||||
display_name: group_details.display_name.to_string(),
|
display_name: group_details.display_name.to_string(),
|
||||||
creation_date: group_details.creation_date,
|
creation_date: group_details.creation_date,
|
||||||
uuid: group_details.uuid.into_string(),
|
uuid: group_details.uuid.into_string(),
|
||||||
attributes: group_details
|
attributes,
|
||||||
.attributes
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| {
|
|
||||||
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
|
||||||
})
|
|
||||||
.collect::<FieldResult<Vec<_>>>()?,
|
|
||||||
schema,
|
schema,
|
||||||
_phantom: std::marker::PhantomData,
|
_phantom: std::marker::PhantomData,
|
||||||
})
|
})
|
||||||
@@ -607,6 +598,19 @@ impl<Handler: BackendHandler> AttributeValue<Handler> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<Handler: BackendHandler> AttributeValue<Handler> {
|
||||||
|
fn from_domain(value: DomainAttributeValue, schema: DomainAttributeSchema) -> Self {
|
||||||
|
Self {
|
||||||
|
attribute: value,
|
||||||
|
schema: AttributeSchema::<Handler> {
|
||||||
|
schema,
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
|
},
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<Handler: BackendHandler> Clone for AttributeValue<Handler> {
|
impl<Handler: BackendHandler> Clone for AttributeValue<Handler> {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -661,18 +665,136 @@ pub fn serialize_attribute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<Handler: BackendHandler> AttributeValue<Handler> {
|
impl<Handler: BackendHandler> AttributeValue<Handler> {
|
||||||
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> FieldResult<Self> {
|
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> Option<Self> {
|
||||||
match schema.get_attribute_schema(&a.name) {
|
schema
|
||||||
Some(s) => Ok(AttributeValue::<Handler> {
|
.get_attribute_schema(&a.name)
|
||||||
attribute: a,
|
.map(|s| AttributeValue::<Handler>::from_domain(a, s.clone()))
|
||||||
schema: AttributeSchema::<Handler> {
|
}
|
||||||
schema: s.clone(),
|
|
||||||
_phantom: std::marker::PhantomData,
|
fn user_attributes_from_schema(
|
||||||
},
|
user: &mut DomainUser,
|
||||||
_phantom: std::marker::PhantomData,
|
schema: &PublicSchema,
|
||||||
}),
|
) -> Vec<AttributeValue<Handler>> {
|
||||||
None => Err(FieldError::from(format!("Unknown attribute {}", &a.name))),
|
let user_attributes = std::mem::take(&mut user.attributes);
|
||||||
}
|
let mut all_attributes = schema
|
||||||
|
.get_schema()
|
||||||
|
.user_attributes
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.is_hardcoded)
|
||||||
|
.flat_map(|attribute| {
|
||||||
|
let value = match attribute.name.as_str() {
|
||||||
|
"user_id" => Some(Serialized::from(&user.user_id)),
|
||||||
|
"creation_date" => Some(Serialized::from(&user.creation_date)),
|
||||||
|
"mail" => Some(Serialized::from(&user.email)),
|
||||||
|
"uuid" => Some(Serialized::from(&user.uuid)),
|
||||||
|
"display_name" => user.display_name.as_ref().map(Serialized::from),
|
||||||
|
"avatar" | "first_name" | "last_name" => None,
|
||||||
|
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
|
||||||
|
};
|
||||||
|
value.map(|v| (attribute, v))
|
||||||
|
})
|
||||||
|
.map(|(attribute, value)| {
|
||||||
|
AttributeValue::<Handler>::from_domain(
|
||||||
|
DomainAttributeValue {
|
||||||
|
name: attribute.name.clone(),
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
attribute.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
user_attributes
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|a| {
|
||||||
|
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
|
||||||
|
})
|
||||||
|
.for_each(|value| all_attributes.push(value));
|
||||||
|
all_attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_attributes_from_schema(
|
||||||
|
group: &mut DomainGroup,
|
||||||
|
schema: &PublicSchema,
|
||||||
|
) -> Vec<AttributeValue<Handler>> {
|
||||||
|
let group_attributes = std::mem::take(&mut group.attributes);
|
||||||
|
let mut all_attributes = schema
|
||||||
|
.get_schema()
|
||||||
|
.group_attributes
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.is_hardcoded)
|
||||||
|
.map(|attribute| {
|
||||||
|
(
|
||||||
|
attribute,
|
||||||
|
match attribute.name.as_str() {
|
||||||
|
"group_id" => Serialized::from(&(group.id.0 as i64)),
|
||||||
|
"creation_date" => Serialized::from(&group.creation_date),
|
||||||
|
"uuid" => Serialized::from(&group.uuid),
|
||||||
|
"display_name" => Serialized::from(&group.display_name),
|
||||||
|
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|(attribute, value)| {
|
||||||
|
AttributeValue::<Handler>::from_domain(
|
||||||
|
DomainAttributeValue {
|
||||||
|
name: attribute.name.clone(),
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
attribute.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
group_attributes
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|a| {
|
||||||
|
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
||||||
|
})
|
||||||
|
.for_each(|value| all_attributes.push(value));
|
||||||
|
all_attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_details_attributes_from_schema(
|
||||||
|
group: &mut GroupDetails,
|
||||||
|
schema: &PublicSchema,
|
||||||
|
) -> Vec<AttributeValue<Handler>> {
|
||||||
|
let group_attributes = std::mem::take(&mut group.attributes);
|
||||||
|
let mut all_attributes = schema
|
||||||
|
.get_schema()
|
||||||
|
.group_attributes
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.is_hardcoded)
|
||||||
|
.map(|attribute| {
|
||||||
|
(
|
||||||
|
attribute,
|
||||||
|
match attribute.name.as_str() {
|
||||||
|
"group_id" => Serialized::from(&(group.group_id.0 as i64)),
|
||||||
|
"creation_date" => Serialized::from(&group.creation_date),
|
||||||
|
"uuid" => Serialized::from(&group.uuid),
|
||||||
|
"display_name" => Serialized::from(&group.display_name),
|
||||||
|
_ => panic!("Unexpected hardcoded attribute: {}", attribute.name),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|(attribute, value)| {
|
||||||
|
AttributeValue::<Handler>::from_domain(
|
||||||
|
DomainAttributeValue {
|
||||||
|
name: attribute.name.clone(),
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
attribute.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
group_attributes
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|a| {
|
||||||
|
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
||||||
|
})
|
||||||
|
.for_each(|value| all_attributes.push(value));
|
||||||
|
all_attributes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,7 +949,6 @@ mod tests {
|
|||||||
|
|
||||||
let schema = schema(Query::<MockTestBackendHandler>::new());
|
let schema = schema(Query::<MockTestBackendHandler>::new());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
execute(QUERY, None, &schema, &Variables::new(), &context).await,
|
|
||||||
Ok((
|
Ok((
|
||||||
graphql_value!(
|
graphql_value!(
|
||||||
{
|
{
|
||||||
@@ -835,10 +956,26 @@ mod tests {
|
|||||||
"id": "bob",
|
"id": "bob",
|
||||||
"email": "bob@bobbers.on",
|
"email": "bob@bobbers.on",
|
||||||
"creationDate": "1970-01-01T00:00:00.042+00:00",
|
"creationDate": "1970-01-01T00:00:00.042+00:00",
|
||||||
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
|
||||||
"firstName": "Bob",
|
"firstName": "Bob",
|
||||||
"lastName": "Bobberson",
|
"lastName": "Bobberson",
|
||||||
|
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
||||||
"attributes": [{
|
"attributes": [{
|
||||||
|
"name": "creation_date",
|
||||||
|
"value": ["1970-01-01T00:00:00.042+00:00"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mail",
|
||||||
|
"value": ["bob@bobbers.on"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"value": ["bob"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uuid",
|
||||||
|
"value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
|
||||||
|
},
|
||||||
|
{
|
||||||
"name": "first_name",
|
"name": "first_name",
|
||||||
"value": ["Bob"],
|
"value": ["Bob"],
|
||||||
},
|
},
|
||||||
@@ -852,6 +989,22 @@ mod tests {
|
|||||||
"creationDate": "1970-01-01T00:00:00.000000042+00:00",
|
"creationDate": "1970-01-01T00:00:00.000000042+00:00",
|
||||||
"uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
"uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
||||||
"attributes": [{
|
"attributes": [{
|
||||||
|
"name": "creation_date",
|
||||||
|
"value": ["1970-01-01T00:00:00.000000042+00:00"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "display_name",
|
||||||
|
"value": ["Bobbersons"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "group_id",
|
||||||
|
"value": ["3"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uuid",
|
||||||
|
"value": ["a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
|
||||||
|
},
|
||||||
|
{
|
||||||
"name": "club_name",
|
"name": "club_name",
|
||||||
"value": ["Gang of Four"],
|
"value": ["Gang of Four"],
|
||||||
},
|
},
|
||||||
@@ -862,12 +1015,29 @@ mod tests {
|
|||||||
"displayName": "Jefferees",
|
"displayName": "Jefferees",
|
||||||
"creationDate": "1970-01-01T00:00:00.000000012+00:00",
|
"creationDate": "1970-01-01T00:00:00.000000012+00:00",
|
||||||
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
|
||||||
"attributes": [],
|
"attributes": [{
|
||||||
|
"name": "creation_date",
|
||||||
|
"value": ["1970-01-01T00:00:00.000000012+00:00"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "display_name",
|
||||||
|
"value": ["Jefferees"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "group_id",
|
||||||
|
"value": ["7"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uuid",
|
||||||
|
"value": ["b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"],
|
||||||
|
},
|
||||||
|
],
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
vec![]
|
vec![]
|
||||||
))
|
)),
|
||||||
|
execute(QUERY, None, &schema, &Variables::new(), &context).await
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user