From dcba3d17dcacb273dcd0a5dc15cc0b67f73b2bbb Mon Sep 17 00:00:00 2001 From: Austin Alvarado Date: Fri, 9 Feb 2024 05:31:46 +0000 Subject: [PATCH] app: Add support for user-created attributes Note: This PR doesn't handle errors around Jpeg files very well. Co-authored-by: Bojidar Marinov Co-authored-by: Austin Alvarado --- app/Cargo.toml | 4 + .../get_user_attributes_schema.graphql | 1 + app/queries/get_user_details.graphql | 21 +- app/src/components/avatar.rs | 1 + app/src/components/create_user.rs | 180 ++++++-- app/src/components/form/attribute_input.rs | 190 ++++++++ app/src/components/form/date_input.rs | 49 ++ app/src/components/form/file_input.rs | 238 ++++++++++ app/src/components/form/mod.rs | 3 + app/src/components/user_details.rs | 44 +- app/src/components/user_details_form.rs | 422 +++++++----------- app/src/infra/functional.rs | 25 +- app/src/infra/mod.rs | 1 + app/src/infra/schema.rs | 24 +- app/src/infra/tooltip.rs | 12 + server/src/infra/graphql/query.rs | 252 +++++++++-- 16 files changed, 1094 insertions(+), 373 deletions(-) create mode 100644 app/src/components/form/attribute_input.rs create mode 100644 app/src/components/form/date_input.rs create mode 100644 app/src/components/form/file_input.rs create mode 100644 app/src/infra/tooltip.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index 5ce750a..831a816 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -37,12 +37,16 @@ version = "0.3" features = [ "Document", "Element", + "Event", "FileReader", + "FormData", "HtmlDocument", + "HtmlFormElement", "HtmlInputElement", "HtmlOptionElement", "HtmlOptionsCollection", "HtmlSelectElement", + "SubmitEvent", "console", ] diff --git a/app/queries/get_user_attributes_schema.graphql b/app/queries/get_user_attributes_schema.graphql index 0560285..be0a713 100644 --- a/app/queries/get_user_attributes_schema.graphql +++ b/app/queries/get_user_attributes_schema.graphql @@ -8,6 +8,7 @@ query GetUserAttributesSchema { isVisible isEditable isHardcoded + isReadonly } } } diff --git a/app/queries/get_user_details.graphql b/app/queries/get_user_details.graphql index 97370fb..cb5128e 100644 --- a/app/queries/get_user_details.graphql +++ b/app/queries/get_user_details.graphql @@ -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 + } + } } } diff --git a/app/src/components/avatar.rs b/app/src/components/avatar.rs index beb5ac9..1d79c51 100644 --- a/app/src/components/avatar.rs +++ b/app/src/components/avatar.rs @@ -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" )] diff --git a/app/src/components/create_user.rs b/app/src/components/create_user.rs index c50777e..eded954 100644 --- a/app/src/components/create_user.rs +++ b/app/src/components/create_user.rs @@ -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, form: yew_form::Form, + attributes_schema: Option>, + 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), SubmitForm, CreateUserResponse(Result), SuccessfulCreation, @@ -79,21 +100,56 @@ impl CommonComponent for CreateUserForm { ) -> Result { 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::().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::( @@ -177,11 +233,20 @@ impl Component for CreateUserForm { type Message = Msg; type Properties = (); - fn create(_: &Context) -> Self { - Self { + fn create(ctx: &Context) -> Self { + let mut component = Self { common: CommonComponentParts::::create(), form: yew_form::Form::::new(CreateUserModel::default()), - } + attributes_schema: None, + form_ref: NodeRef::default(), + }; + component.common.call_graphql::( + ctx, + get_user_attributes_schema::Variables {}, + Msg::ListAttributesResponse, + "Error trying to fetch user schema", + ); + component } fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { @@ -192,38 +257,22 @@ impl Component for CreateUserForm { let link = &ctx.link(); html! {
-
+ form={&self.form} required=true label="User name" field_name="username" oninput={link.callback(|_| Msg::Update)} /> - - form={&self.form} - required=true - label="Email" - field_name="email" - input_type="email" - oninput={link.callback(|_| Msg::Update)} /> - - form={&self.form} - label="Display name" - field_name="display_name" - autocomplete="name" - oninput={link.callback(|_| Msg::Update)} /> - - form={&self.form} - label="First name" - field_name="first_name" - autocomplete="given-name" - oninput={link.callback(|_| Msg::Update)} /> - - 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::>() + } 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! { + ::into(attribute_schema.attribute_type.clone())} + /> + } + } else { + html! { + ::into(attribute_schema.attribute_type.clone())} + /> + } + } +} + +type AttributeValue = (String, Vec); + +fn get_values_from_form_data( + schema: Vec<&Attribute>, + form: &FormData, +) -> Result> { + schema + .into_iter() + .map(|attr| -> Result { + 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::>(); + ensure!( + val.len() <= 1 || attr.is_list, + "Multiple values supplied for non-list attribute {}", + attr.name + ); + Ok((attr.name.clone(), val)) + }) + .collect() +} diff --git a/app/src/components/form/attribute_input.rs b/app/src/components/form/attribute_input.rs new file mode 100644 index 0000000..6886c67 --- /dev/null +++ b/app/src/components/form/attribute_input.rs @@ -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, +} + +#[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! { + + } + } + AttributeType::Jpeg => { + return html! { + + } + } + }; + + html! { + + } +} + +#[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::() + .expect("Tooltip element should exist"), + ); + || {} + }, + tooltip_ref.clone(), + ); + + html! { + + } +} + +#[derive(Properties, PartialEq)] +pub struct SingleAttributeInputProps { + pub name: String, + pub attribute_type: AttributeType, + #[prop_or(None)] + pub value: Option, +} + +#[function_component(SingleAttributeInput)] +pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html { + html! { +
+ +
+ +
+
+ } +} + +#[derive(Properties, PartialEq)] +pub struct ListAttributeInputProps { + pub name: String, + pub attribute_type: AttributeType, + #[prop_or(vec!())] + pub values: Vec, +} + +pub enum ListAttributeInputMsg { + Remove(usize), + Append, +} + +pub struct ListAttributeInput { + indices: Vec, + next_index: usize, + values: Vec, +} +impl Component for ListAttributeInput { + type Message = ListAttributeInputMsg; + type Properties = ListAttributeInputProps; + + fn create(ctx: &Context) -> Self { + let values = ctx.props().values.clone(); + Self { + indices: (0..values.len()).collect(), + next_index: values.len(), + values, + } + } + + fn update(&mut self, _ctx: &Context, 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) -> 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) -> Html { + let props = &ctx.props(); + let link = &ctx.link(); + html! { +
+ +
+ {self.indices.iter().map(|&i| html! { +
+ + +
+ }).collect::()} + +
+
+ } + } +} diff --git a/app/src/components/form/date_input.rs b/app/src/components/form/date_input.rs new file mode 100644 index 0000000..6c6d7ca --- /dev/null +++ b/app/src/components/form/date_input.rs @@ -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, +} + +#[function_component(DateTimeInput)] +pub fn date_time_input(props: &DateTimeInputProps) -> Html { + let value = use_state(|| { + props + .value + .as_ref() + .and_then(|x| DateTime::::from_str(x).ok()) + }); + + html! { +
+ | v.to_rfc3339())} /> + | v.naive_utc().to_string())} + onchange={move |e: Event| { + let string_val = + e.target() + .expect("Event should have target") + .unchecked_into::() + .value(); + value.set( + NaiveDateTime::from_str(&string_val) + .ok() + .map(|x| DateTime::from_utc(x, Utc)) + ) + }} /> + {"UTC"} +
+ } +} diff --git a/app/src/components/form/file_input.rs b/app/src/components/form/file_input.rs new file mode 100644 index 0000000..805f425 --- /dev/null +++ b/app/src/components/form/file_input.rs @@ -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, + contents: Option>, +} + +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 { + if s.is_empty() { + Ok(JsFile::default()) + } else { + bail!("Building file from non-empty string") + } + } +} + +fn to_base64(file: &JsFile) -> Result { + 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, + reader: Option, +} + +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>), +} + +#[derive(Properties, Clone, PartialEq, Eq)] +pub struct Props { + pub name: AttrValue, + pub value: Option, +} + +impl Component for JpegFileInput { + type Message = Msg; + type Properties = Props; + + fn create(ctx: &Context) -> 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) -> 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, 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) -> 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! { +
+
+ + +
+
+ +
+
+ { + if !avatar_string.is_empty() { + html!{ + Avatar + } + } else { html! {} } + } +
+
+ } + } +} + +impl JpegFileInput { + fn upload_files(files: Option) -> 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() +} diff --git a/app/src/components/form/mod.rs b/app/src/components/form/mod.rs index f46bded..a519359 100644 --- a/app/src/components/form/mod.rs +++ b/app/src/components/form/mod.rs @@ -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; diff --git a/app/src/components/user_details.rs b/app/src/components/user_details.rs index f11ac4c..431646a 100644 --- a/app/src/components/user_details.rs +++ b/app/src/components/user_details.rs @@ -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, /// 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_and_schema: Option<(User, Vec)>, +} + +impl UserDetails { + fn mut_groups(&mut self) -> &mut Vec { + &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 for UserDetails { fn handle_msg(&mut self, _: &Context, msg: ::Message) -> Result { 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 { let mut table = Self { common: CommonComponentParts::::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) -> Html { - match (&self.user, &self.common.error) { - (None, None) => html! {{"Loading..."}}, - (None, Some(e)) => html! {
{"Error: "}{e.to_string()}
}, - (Some(u), error) => { + match (&self.user_and_schema, &self.common.error) { + (Some((u, schema)), error) => { html! { <>

{u.id.to_string()}

@@ -207,13 +215,19 @@ impl Component for UserDetails {
{"User details"}
- + {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! {
{"Error: "}{e.to_string()}
}, } } } diff --git a/app/src/components/user_details_form.rs b/app/src/components/user_details_form.rs index 3c428c3..318608c 100644 --- a/app/src/components/user_details_form.rs +++ b/app/src/components/user_details_form.rs @@ -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, - contents: Option>, -} - -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 { - 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, form: yew_form::Form, - // None means that the avatar hasn't changed. - avatar: Option, - reader: Option, /// 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>), /// We got the response from the server about our update message. UserUpdated(Result), } @@ -98,6 +63,8 @@ pub enum Msg { pub struct Props { /// The current user details. pub user: User, + pub user_attributes_schema: Vec, + pub is_admin: bool, } impl CommonComponent for UserDetailsForm { @@ -108,53 +75,8 @@ impl CommonComponent for UserDetailsForm { ) -> Result { 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::::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) -> 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! {
- + {&self.user.id} - - {&self.user.creation_date.naive_local().date()} - - - {&self.user.uuid} - - - form={&self.form} - required=true - label="Email" - field_name="email" - input_type="email" - oninput={link.callback(|_| Msg::Update)} /> - - form={&self.form} - label="Display name" - field_name="display_name" - autocomplete="name" - oninput={link.callback(|_| Msg::Update)} /> - - form={&self.form} - label="First name" - field_name="first_name" - autocomplete="given-name" - oninput={link.callback(|_| Msg::Update)} /> - - form={&self.form} - label="Last name" - field_name="last_name" - autocomplete="family-name" - oninput={link.callback(|_| Msg::Update)} /> -
- -
-
-
- -
-
- -
-
- { - if !avatar_string.is_empty() { - html!{ - Avatar - } - } else { html! {} } - } -
-
-
-
+ { + ctx + .props() + .user_attributes_schema + .iter() + .filter(|a| a.is_hardcoded && a.name != "user_id") + .map(display_field) + .collect::>() + } + { + ctx + .props() + .user_attributes_schema + .iter() + .filter(|a| !a.is_hardcoded) + .map(display_field) + .collect::>() + } ); + +fn get_values_from_form_data( + schema: Vec<&AttributeSchema>, + form: &FormData, +) -> Result> { + schema + .into_iter() + .map(|attr| -> Result { + 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::>(); + 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! { + ::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! { + ::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! { + + {values.into_iter().map(|x| html!{
{x}
}).collect::>()} +
+ } +} + impl UserDetailsForm { fn submit_user_update_form(&mut self, ctx: &Context) -> Result { 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::().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> = if all_values.is_empty() { + None + } else { + Some(all_values.iter().map(|(name, _)| name.clone()).collect()) + }; + let insert_attributes: Option> = + 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) -> 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 { - 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)) - } - } } diff --git a/app/src/infra/functional.rs b/app/src/infra/functional.rs index 8d3fac5..6e5fd39 100644 --- a/app/src/infra/functional.rs +++ b/app/src/infra/functional.rs @@ -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( ) -> UseStateHandle> where QueryType: GraphQLQuery + 'static, + ::Variables: std::cmp::PartialEq + Clone, ::ResponseData: std::cmp::PartialEq, { let loadable_result: UseStateHandle> = use_state_eq(|| LoadableResult::Loading); { let loadable_result = loadable_result.clone(); - use_effect(move || { - let task = HostService::graphql_query::(variables, "Failed graphql query"); + use_effect_with_deps( + move |variables| { + let task = HostService::graphql_query::( + 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() } diff --git a/app/src/infra/mod.rs b/app/src/infra/mod.rs index 4c46411..056d4c4 100644 --- a/app/src/infra/mod.rs +++ b/app/src/infra/mod.rs @@ -5,3 +5,4 @@ pub mod functional; pub mod graphql; pub mod modal; pub mod schema; +pub mod tooltip; diff --git a/app/src/infra/schema.rs b/app/src/infra/schema.rs index b55aab6..24099b4 100644 --- a/app/src/infra/schema.rs +++ b/app/src/infra/schema.rs @@ -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 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, } } } diff --git a/app/src/infra/tooltip.rs b/app/src/infra/tooltip.rs new file mode 100644 index 0000000..638f1a9 --- /dev/null +++ b/app/src/infra/tooltip.rs @@ -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; +} diff --git a/server/src/infra/graphql/query.rs b/server/src/infra/graphql/query.rs index 19d8b97..4265609 100644 --- a/server/src/infra/graphql/query.rs +++ b/server/src/infra/graphql/query.rs @@ -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 { impl User { pub fn from_user(mut user: DomainUser, schema: Arc) -> FieldResult { - let attributes = std::mem::take(&mut user.attributes); + let attributes = AttributeValue::::user_attributes_from_schema(&mut user, &schema); Ok(Self { user, - attributes: attributes - .into_iter() - .map(|a| { - AttributeValue::::from_schema(a, &schema.get_schema().user_attributes) - }) - .collect::>>()?, + attributes, schema, groups: None, _phantom: std::marker::PhantomData, @@ -370,42 +367,36 @@ pub struct Group { impl Group { pub fn from_group( - group: DomainGroup, + mut group: DomainGroup, schema: Arc, ) -> FieldResult> { + let attributes = + AttributeValue::::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::::from_schema(a, &schema.get_schema().group_attributes) - }) - .collect::>>()?, + attributes, schema, _phantom: std::marker::PhantomData, }) } pub fn from_group_details( - group_details: GroupDetails, + mut group_details: GroupDetails, schema: Arc, ) -> FieldResult> { + let attributes = AttributeValue::::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::::from_schema(a, &schema.get_schema().group_attributes) - }) - .collect::>>()?, + attributes, schema, _phantom: std::marker::PhantomData, }) @@ -607,6 +598,19 @@ impl AttributeValue { } } +impl AttributeValue { + fn from_domain(value: DomainAttributeValue, schema: DomainAttributeSchema) -> Self { + Self { + attribute: value, + schema: AttributeSchema:: { + schema, + _phantom: std::marker::PhantomData, + }, + _phantom: std::marker::PhantomData, + } + } +} + impl Clone for AttributeValue { fn clone(&self) -> Self { Self { @@ -661,18 +665,136 @@ pub fn serialize_attribute( } impl AttributeValue { - fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> FieldResult { - match schema.get_attribute_schema(&a.name) { - Some(s) => Ok(AttributeValue:: { - attribute: a, - schema: AttributeSchema:: { - 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 { + schema + .get_attribute_schema(&a.name) + .map(|s| AttributeValue::::from_domain(a, s.clone())) + } + + fn user_attributes_from_schema( + user: &mut DomainUser, + schema: &PublicSchema, + ) -> Vec> { + 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::::from_domain( + DomainAttributeValue { + name: attribute.name.clone(), + value, + }, + attribute.clone(), + ) + }) + .collect::>(); + user_attributes + .into_iter() + .flat_map(|a| { + AttributeValue::::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> { + 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::::from_domain( + DomainAttributeValue { + name: attribute.name.clone(), + value, + }, + attribute.clone(), + ) + }) + .collect::>(); + group_attributes + .into_iter() + .flat_map(|a| { + AttributeValue::::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> { + 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::::from_domain( + DomainAttributeValue { + name: attribute.name.clone(), + value, + }, + attribute.clone(), + ) + }) + .collect::>(); + group_attributes + .into_iter() + .flat_map(|a| { + AttributeValue::::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::::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 ); }