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:
Austin Alvarado 2024-02-09 05:31:46 +00:00 committed by nitnelave
parent 1f3f73585b
commit dcba3d17dc
16 changed files with 1094 additions and 373 deletions

View File

@ -37,12 +37,16 @@ version = "0.3"
features = [
"Document",
"Element",
"Event",
"FileReader",
"FormData",
"HtmlDocument",
"HtmlFormElement",
"HtmlInputElement",
"HtmlOptionElement",
"HtmlOptionsCollection",
"HtmlSelectElement",
"SubmitEvent",
"console",
]

View File

@ -8,6 +8,7 @@ query GetUserAttributesSchema {
isVisible
isEditable
isHardcoded
isReadonly
}
}
}

View File

@ -2,15 +2,30 @@ query GetUserDetails($id: String!) {
user(userId: $id) {
id
email
displayName
firstName
lastName
avatar
displayName
creationDate
uuid
groups {
id
displayName
}
attributes {
name
value
}
}
schema {
userSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
isReadonly
}
}
}
}

View File

@ -6,6 +6,7 @@ use yew::{function_component, html, virtual_dom::AttrValue, Properties};
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_user_details.graphql",
variables_derives = "Clone,PartialEq,Eq",
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
custom_scalars_module = "crate::infra::graphql"
)]

View File

@ -1,22 +1,45 @@
use crate::{
components::{
form::{field::Field, submit::Submit},
form::{
attribute_input::{ListAttributeInput, SingleAttributeInput},
field::Field,
submit::Submit,
},
router::AppRoute,
},
convert_attribute_type,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
schema::AttributeType,
},
};
use anyhow::{bail, Result};
use anyhow::{anyhow, ensure, Result};
use gloo_console::log;
use graphql_client::GraphQLQuery;
use lldap_auth::{opaque, registration};
use validator::validate_email;
use validator_derive::Validate;
use web_sys::{FormData, HtmlFormElement};
use yew::prelude::*;
use yew_form_derive::Model;
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)]
#[graphql(
schema_path = "../schema.graphql",
@ -29,17 +52,14 @@ pub struct CreateUser;
pub struct CreateUserForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<CreateUserModel>,
attributes_schema: Option<Vec<Attribute>>,
form_ref: NodeRef,
}
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct CreateUserModel {
#[validate(length(min = 1, message = "Username is required"))]
username: String,
#[validate(email(message = "A valid email is required"))]
email: String,
display_name: String,
first_name: String,
last_name: String,
#[validate(custom(
function = "empty_or_long",
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 {
Update,
ListAttributesResponse(Result<ResponseData>),
SubmitForm,
CreateUserResponse(Result<create_user::ResponseData>),
SuccessfulCreation,
@ -79,21 +100,56 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::ListAttributesResponse(schema) => {
self.attributes_schema =
Some(schema?.schema.user_schema.attributes.into_iter().collect());
Ok(true)
}
Msg::SubmitForm => {
if !self.form.validate() {
bail!("Check the form for errors");
ensure!(self.form.validate(), "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 to_option = |s: String| if s.is_empty() { None } else { Some(s) };
let req = create_user::Variables {
user: create_user::CreateUserInput {
id: model.username,
email: Some(model.email),
displayName: to_option(model.display_name),
firstName: to_option(model.first_name),
lastName: to_option(model.last_name),
email: None,
displayName: None,
firstName: None,
lastName: None,
avatar: None,
attributes: None,
attributes,
},
};
self.common.call_graphql::<CreateUser, _>(
@ -177,11 +233,20 @@ impl Component for CreateUserForm {
type Message = Msg;
type Properties = ();
fn create(_: &Context<Self>) -> Self {
Self {
fn create(ctx: &Context<Self>) -> Self {
let mut component = Self {
common: CommonComponentParts::<Self>::create(),
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 {
@ -192,38 +257,22 @@ impl Component for CreateUserForm {
let link = &ctx.link();
html! {
<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>
form={&self.form}
required=true
label="User name"
field_name="username"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
required=true
label="Email"
field_name="email"
input_type="email"
oninput={link.callback(|_| Msg::Update)} />
<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)} />
{
self.attributes_schema
.iter()
.flatten()
.filter(|a| !a.is_readonly)
.map(get_custom_attribute_input)
.collect::<Vec<_>>()
}
<Field<CreateUserModel>
form={&self.form}
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()
}

View 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>
}
}
}

View 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>
}
}

View 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()
}

View File

@ -1,5 +1,8 @@
pub mod attribute_input;
pub mod checkbox;
pub mod date_input;
pub mod field;
pub mod file_input;
pub mod select;
pub mod static_value;
pub mod submit;

View File

@ -5,6 +5,7 @@ use crate::{
router::{AppRoute, Link},
user_details_form::UserDetailsForm,
},
convert_attribute_type,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
@ -22,12 +23,23 @@ pub struct GetUserDetails;
pub type User = get_user_details::GetUserDetailsUser;
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 {
common: CommonComponentParts<Self>,
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet.
user: Option<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.
@ -50,22 +62,20 @@ impl CommonComponent<UserDetails> for UserDetails {
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
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) => {
self.user = None;
self.user_and_schema = None;
bail!("Error getting user details: {}", e);
}
},
Msg::OnError(e) => return Err(e),
Msg::OnUserAddedToGroup(group) => {
self.user.as_mut().unwrap().groups.push(group);
self.mut_groups().push(group);
}
Msg::OnUserRemovedFromGroup((_, group_id)) => {
self.user
.as_mut()
.unwrap()
.groups
.retain(|g| g.id != group_id);
self.mut_groups().retain(|g| g.id != group_id);
}
}
Ok(true)
@ -178,7 +188,7 @@ impl Component for UserDetails {
fn create(ctx: &Context<Self>) -> Self {
let mut table = Self {
common: CommonComponentParts::<Self>::create(),
user: None,
user_and_schema: None,
};
table.get_user_details(ctx);
table
@ -189,10 +199,8 @@ impl Component for UserDetails {
}
fn view(&self, ctx: &Context<Self>) -> Html {
match (&self.user, &self.common.error) {
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
(Some(u), error) => {
match (&self.user_and_schema, &self.common.error) {
(Some((u, schema)), error) => {
html! {
<>
<h3>{u.id.to_string()}</h3>
@ -207,13 +215,19 @@ impl Component for UserDetails {
<div>
<h5 class="row m-3 fw-bold">{"User details"}</h5>
</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_add_group_button(ctx, u)}
{self.view_messages(error)}
</>
}
}
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
}
}
}

View File

@ -1,59 +1,32 @@
use std::{fmt::Display, str::FromStr};
use crate::{
components::{
form::{field::Field, static_value::StaticValue, submit::Submit},
user_details::User,
form::{
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 validator::HasLen;
use validator_derive::Validate;
use web_sys::{FileList, HtmlInputElement, InputEvent};
use web_sys::{FormData, HtmlFormElement};
use yew::prelude::*;
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.
#[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.
@ -71,25 +44,17 @@ pub struct UpdateUser;
pub struct UserDetailsForm {
common: CommonComponentParts<Self>,
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.
just_updated: bool,
user: User,
form_ref: NodeRef,
}
pub enum Msg {
/// A form field changed.
Update,
/// A new file was selected.
FileSelected(File),
/// The "Submit" button was clicked.
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.
UserUpdated(Result<update_user::ResponseData>),
}
@ -98,6 +63,8 @@ pub enum Msg {
pub struct Props {
/// The current user details.
pub user: User,
pub user_attributes_schema: Vec<AttributeSchema>,
pub is_admin: bool,
}
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
@ -108,53 +75,8 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
) -> Result<bool> {
match msg {
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::ClearAvatarClicked => {
self.avatar = Some(JsFile::default());
Ok(true)
}
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 {
email: ctx.props().user.email.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 {
common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::new(model),
avatar: None,
just_updated: false,
reader: None,
user: ctx.props().user.clone(),
form_ref: NodeRef::default(),
}
}
@ -192,93 +111,41 @@ impl Component for UserDetailsForm {
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()
let can_edit =
|a: &AttributeSchema| (ctx.props().is_admin || a.is_editable) && !a.is_readonly;
let display_field = |a: &AttributeSchema| {
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! {
<div class="py-3">
<form class="form">
<form
class="form"
ref={self.form_ref.clone()}>
<StaticValue label="User ID" id="userId">
<i>{&self.user.id}</i>
</StaticValue>
<StaticValue label="Creation date" id="creationDate">
{&self.user.creation_date.naive_local().date()}
</StaticValue>
<StaticValue label="UUID" id="uuid">
{&self.user.uuid}
</StaticValue>
<Field<UserModel>
form={&self.form}
required=true
label="Email"
field_name="email"
input_type="email"
oninput={link.callback(|_| Msg::Update)} />
<Field<UserModel>
form={&self.form}
label="Display name"
field_name="display_name"
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>
{
ctx
.props()
.user_attributes_schema
.iter()
.filter(|a| a.is_hardcoded && a.name != "user_id")
.map(display_field)
.collect::<Vec<_>>()
}
{
ctx
.props()
.user_attributes_schema
.iter()
.filter(|a| !a.is_hardcoded)
.map(display_field)
.collect::<Vec<_>>()
}
<Submit
text="Save changes"
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 {
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
if !self.form.validate() {
bail!("Invalid inputs");
}
if let Some(JsFile {
file: Some(_),
contents: None,
}) = &self.avatar
{
bail!("Image file hasn't finished loading, try again");
}
let base_user = &self.user;
// TODO: Handle unloaded files.
// if let Some(JsFile {
// file: Some(_),
// contents: None,
// }) = &self.avatar
// {
// bail!("Image file hasn't finished loading, try again");
// }
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 {
id: self.user.id.clone(),
email: None,
@ -325,23 +309,8 @@ impl UserDetailsForm {
insertAttributes: 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);
}
if let Some(avatar) = &self.avatar {
user_input.avatar = Some(to_base64(avatar)?);
}
user_input.removeAttributes = remove_attributes;
user_input.insertAttributes = insert_attributes;
// Nothing changed.
if user_input == default_user_input {
return Ok(false);
@ -361,52 +330,7 @@ impl UserDetailsForm {
let model = self.form.model();
self.user.email = model.email;
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;
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))
}
}
}

View File

@ -2,7 +2,7 @@ use crate::infra::api::HostService;
use anyhow::Result;
use graphql_client::GraphQLQuery;
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.
#[derive(Debug)]
@ -31,22 +31,29 @@ pub fn use_graphql_call<QueryType>(
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
where
QueryType: GraphQLQuery + 'static,
<QueryType as graphql_client::GraphQLQuery>::Variables: std::cmp::PartialEq + Clone,
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
{
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
use_state_eq(|| LoadableResult::Loading);
{
let loadable_result = loadable_result.clone();
use_effect(move || {
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
use_effect_with_deps(
move |variables| {
let task = HostService::graphql_query::<QueryType>(
variables.clone(),
"Failed graphql query",
);
spawn_local(async move {
let response = task.await;
loadable_result.set(LoadableResult::Loaded(response));
});
spawn_local(async move {
let response = task.await;
loadable_result.set(LoadableResult::Loaded(response));
});
|| ()
})
|| ()
},
variables,
)
}
loadable_result.clone()
}

View File

@ -5,3 +5,4 @@ pub mod functional;
pub mod graphql;
pub mod modal;
pub mod schema;
pub mod tooltip;

View File

@ -2,7 +2,7 @@ use anyhow::Result;
use std::{fmt::Display, str::FromStr};
use validator::ValidationError;
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributeType {
String,
Integer,
@ -34,25 +34,25 @@ impl FromStr for AttributeType {
#[macro_export]
macro_rules! convert_attribute_type {
($source_type:ty) => {
impl From<$source_type> for AttributeType {
impl From<$source_type> for $crate::infra::schema::AttributeType {
fn from(value: $source_type) -> Self {
match value {
<$source_type>::STRING => AttributeType::String,
<$source_type>::INTEGER => AttributeType::Integer,
<$source_type>::DATE_TIME => AttributeType::DateTime,
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
<$source_type>::STRING => $crate::infra::schema::AttributeType::String,
<$source_type>::INTEGER => $crate::infra::schema::AttributeType::Integer,
<$source_type>::DATE_TIME => $crate::infra::schema::AttributeType::DateTime,
<$source_type>::JPEG_PHOTO => $crate::infra::schema::AttributeType::Jpeg,
_ => panic!("Unknown attribute type"),
}
}
}
impl From<AttributeType> for $source_type {
fn from(value: AttributeType) -> Self {
impl From<$crate::infra::schema::AttributeType> for $source_type {
fn from(value: $crate::infra::schema::AttributeType) -> Self {
match value {
AttributeType::String => <$source_type>::STRING,
AttributeType::Integer => <$source_type>::INTEGER,
AttributeType::DateTime => <$source_type>::DATE_TIME,
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
$crate::infra::schema::AttributeType::String => <$source_type>::STRING,
$crate::infra::schema::AttributeType::Integer => <$source_type>::INTEGER,
$crate::infra::schema::AttributeType::DateTime => <$source_type>::DATE_TIME,
$crate::infra::schema::AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
}
}
}

12
app/src/infra/tooltip.rs Normal file
View 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;
}

View File

@ -7,7 +7,9 @@ use crate::{
ldap::utils::{map_user_field, UserFieldType},
model::UserColumn,
schema::PublicSchema,
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, UserId},
types::{
AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, Serialized, UserId,
},
},
infra::{
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
@ -16,7 +18,7 @@ use crate::{
};
use anyhow::Context as AnyhowContext;
use chrono::{NaiveDateTime, TimeZone};
use juniper::{graphql_object, FieldError, FieldResult, GraphQLInputObject};
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
use serde::{Deserialize, Serialize};
use tracing::{debug, debug_span, Instrument, Span};
@ -247,15 +249,10 @@ pub struct User<Handler: BackendHandler> {
impl<Handler: BackendHandler> User<Handler> {
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 {
user,
attributes: attributes
.into_iter()
.map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
})
.collect::<FieldResult<Vec<_>>>()?,
attributes,
schema,
groups: None,
_phantom: std::marker::PhantomData,
@ -370,42 +367,36 @@ pub struct Group<Handler: BackendHandler> {
impl<Handler: BackendHandler> Group<Handler> {
pub fn from_group(
group: DomainGroup,
mut group: DomainGroup,
schema: Arc<PublicSchema>,
) -> FieldResult<Group<Handler>> {
let attributes =
AttributeValue::<Handler>::group_attributes_from_schema(&mut group, &schema);
Ok(Self {
group_id: group.id.0,
display_name: group.display_name.to_string(),
creation_date: group.creation_date,
uuid: group.uuid.into_string(),
attributes: group
.attributes
.into_iter()
.map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
})
.collect::<FieldResult<Vec<_>>>()?,
attributes,
schema,
_phantom: std::marker::PhantomData,
})
}
pub fn from_group_details(
group_details: GroupDetails,
mut group_details: GroupDetails,
schema: Arc<PublicSchema>,
) -> FieldResult<Group<Handler>> {
let attributes = AttributeValue::<Handler>::group_details_attributes_from_schema(
&mut group_details,
&schema,
);
Ok(Self {
group_id: group_details.group_id.0,
display_name: group_details.display_name.to_string(),
creation_date: group_details.creation_date,
uuid: group_details.uuid.into_string(),
attributes: group_details
.attributes
.into_iter()
.map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
})
.collect::<FieldResult<Vec<_>>>()?,
attributes,
schema,
_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> {
fn clone(&self) -> Self {
Self {
@ -661,18 +665,136 @@ pub fn serialize_attribute(
}
impl<Handler: BackendHandler> AttributeValue<Handler> {
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> FieldResult<Self> {
match schema.get_attribute_schema(&a.name) {
Some(s) => Ok(AttributeValue::<Handler> {
attribute: a,
schema: AttributeSchema::<Handler> {
schema: s.clone(),
_phantom: std::marker::PhantomData,
},
_phantom: std::marker::PhantomData,
}),
None => Err(FieldError::from(format!("Unknown attribute {}", &a.name))),
}
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> Option<Self> {
schema
.get_attribute_schema(&a.name)
.map(|s| AttributeValue::<Handler>::from_domain(a, s.clone()))
}
fn user_attributes_from_schema(
user: &mut DomainUser,
schema: &PublicSchema,
) -> Vec<AttributeValue<Handler>> {
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());
assert_eq!(
execute(QUERY, None, &schema, &Variables::new(), &context).await,
Ok((
graphql_value!(
{
@ -835,10 +956,26 @@ mod tests {
"id": "bob",
"email": "bob@bobbers.on",
"creationDate": "1970-01-01T00:00:00.042+00:00",
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
"firstName": "Bob",
"lastName": "Bobberson",
"uuid": "b1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
"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",
"value": ["Bob"],
},
@ -852,6 +989,22 @@ mod tests {
"creationDate": "1970-01-01T00:00:00.000000042+00:00",
"uuid": "a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8",
"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",
"value": ["Gang of Four"],
},
@ -862,12 +1015,29 @@ mod tests {
"displayName": "Jefferees",
"creationDate": "1970-01-01T00:00:00.000000012+00:00",
"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![]
))
)),
execute(QUERY, None, &schema, &Variables::new(), &context).await
);
}