From 8bd1dec1803182c08ef050e0546404345c5a0ddb Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Mon, 11 Oct 2021 18:41:55 +0200 Subject: [PATCH] app: Implement group management Except group creation --- app/queries/delete_group.graphql | 5 + app/queries/get_group_details.graphql | 10 + app/queries/list_users.graphql | 6 + app/src/components/add_group_member.rs | 202 ++++++++++++++++ app/src/components/app.rs | 36 ++- app/src/components/delete_group.rs | 158 +++++++++++++ app/src/components/group_details.rs | 236 +++++++++++++++++++ app/src/components/group_table.rs | 39 +-- app/src/components/mod.rs | 3 + app/src/components/remove_user_from_group.rs | 30 +-- app/src/components/router.rs | 2 + app/src/components/user_details.rs | 36 ++- 12 files changed, 694 insertions(+), 69 deletions(-) create mode 100644 app/queries/delete_group.graphql create mode 100644 app/queries/get_group_details.graphql create mode 100644 app/src/components/add_group_member.rs create mode 100644 app/src/components/delete_group.rs create mode 100644 app/src/components/group_details.rs diff --git a/app/queries/delete_group.graphql b/app/queries/delete_group.graphql new file mode 100644 index 0000000..3ed0182 --- /dev/null +++ b/app/queries/delete_group.graphql @@ -0,0 +1,5 @@ +mutation DeleteGroupQuery($groupId: Int!) { + deleteGroup(groupId: $groupId) { + ok + } +} diff --git a/app/queries/get_group_details.graphql b/app/queries/get_group_details.graphql new file mode 100644 index 0000000..b338527 --- /dev/null +++ b/app/queries/get_group_details.graphql @@ -0,0 +1,10 @@ +query GetGroupDetails($id: Int!) { + group(groupId: $id) { + id + displayName + users { + id + displayName + } + } +} diff --git a/app/queries/list_users.graphql b/app/queries/list_users.graphql index 211b8b0..0ea4006 100644 --- a/app/queries/list_users.graphql +++ b/app/queries/list_users.graphql @@ -8,3 +8,9 @@ query ListUsersQuery($filters: RequestFilter) { creationDate } } +query ListUserNames($filters: RequestFilter) { + users(filters: $filters) { + id + displayName + } +} diff --git a/app/src/components/add_group_member.rs b/app/src/components/add_group_member.rs new file mode 100644 index 0000000..faac268 --- /dev/null +++ b/app/src/components/add_group_member.rs @@ -0,0 +1,202 @@ +use crate::{ + components::select::{Select, SelectOption, SelectOptionProps}, + infra::api::HostService, +}; +use anyhow::{Error, Result}; +use graphql_client::GraphQLQuery; +use std::collections::HashSet; +use yew::{ + prelude::*, + services::{fetch::FetchTask, ConsoleService}, +}; +use yewtil::NeqAssign; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/add_user_to_group.graphql", + response_derives = "Debug", + variables_derives = "Clone", + custom_scalars_module = "crate::infra::graphql" +)] +pub struct AddUserToGroup; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/list_users.graphql", + response_derives = "Debug,Clone,PartialEq,Eq,Hash", + variables_derives = "Clone", + custom_scalars_module = "crate::infra::graphql" +)] +pub struct ListUserNames; +pub type User = list_user_names::ListUserNamesUsers; + +pub struct AddGroupMemberComponent { + link: ComponentLink, + props: Props, + /// The list of existing users, initially not loaded. + user_list: Option>, + /// The currently selected user. + selected_user: Option, + // Used to keep the request alive long enough. + _task: Option, +} + +pub enum Msg { + UserListResponse(Result), + SubmitAddMember, + AddMemberResponse(Result), + SelectionChanged(Option), +} + +#[derive(yew::Properties, Clone, PartialEq)] +pub struct Props { + pub group_id: i64, + pub users: Vec, + pub on_user_added_to_group: Callback, + pub on_error: Callback, +} + +impl AddGroupMemberComponent { + fn get_user_list(&mut self) { + self._task = HostService::graphql_query::( + list_user_names::Variables { filters: None }, + self.link.callback(Msg::UserListResponse), + "Error trying to fetch user list", + ) + .map_err(|e| { + ConsoleService::log(&e.to_string()); + e + }) + .ok(); + } + + fn submit_add_member(&mut self) -> Result { + let user_id = match self.selected_user.clone() { + None => return Ok(false), + Some(user) => user.id, + }; + self._task = HostService::graphql_query::( + add_user_to_group::Variables { + user: user_id, + group: self.props.group_id, + }, + self.link.callback(Msg::AddMemberResponse), + "Error trying to initiate adding the user to a group", + ) + .map_err(|e| { + ConsoleService::log(&e.to_string()); + e + }) + .ok(); + Ok(true) + } + + fn handle_msg(&mut self, msg: ::Message) -> Result { + match msg { + Msg::UserListResponse(response) => { + self.user_list = Some(response?.users); + } + Msg::SubmitAddMember => return self.submit_add_member(), + Msg::AddMemberResponse(response) => { + response?; + let user = self + .selected_user + .as_ref() + .expect("Could not get selected user") + .clone(); + // Remove the user from the dropdown. + self.props.on_user_added_to_group.emit(user); + } + Msg::SelectionChanged(option_props) => { + self.selected_user = option_props.map(|u| User { + id: u.value, + display_name: u.text, + }); + return Ok(false); + } + } + Ok(true) + } + + fn get_selectable_user_list(&self, user_list: &[User]) -> Vec { + let user_groups = self.props.users.iter().collect::>(); + user_list + .iter() + .filter(|u| !user_groups.contains(u)) + .map(Clone::clone) + .collect() + } +} + +impl Component for AddGroupMemberComponent { + type Message = Msg; + type Properties = Props; + fn create(props: Self::Properties, link: ComponentLink) -> Self { + let mut res = Self { + link, + props, + user_list: None, + selected_user: None, + _task: None, + }; + res.get_user_list(); + res + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + match self.handle_msg(msg) { + Err(e) => { + ConsoleService::error(&e.to_string()); + self.props.on_error.emit(e); + true + } + Ok(b) => b, + } + } + + fn change(&mut self, props: Self::Properties) -> ShouldRender { + self.props.neq_assign(props) + } + + fn view(&self) -> Html { + if let Some(user_list) = &self.user_list { + let to_add_user_list = self.get_selectable_user_list(user_list); + #[allow(unused_braces)] + let make_select_option = |user: User| { + html_nested! { + + } + }; + html! { + <> + + + + + + + + } + } else { + html! { + <> + {"Loading groups"} + + + } + } + } +} diff --git a/app/src/components/app.rs b/app/src/components/app.rs index 96b15a3..6a76e06 100644 --- a/app/src/components/app.rs +++ b/app/src/components/app.rs @@ -2,12 +2,13 @@ use crate::{ components::{ change_password::ChangePasswordForm, create_user::CreateUserForm, + group_details::GroupDetails, + group_table::GroupTable, login::LoginForm, logout::LogoutButton, router::{AppRoute, NavButton}, user_details::UserDetails, user_table::UserTable, - group_table::GroupTable, }, infra::cookies::get_cookie, }; @@ -104,36 +105,28 @@ impl Component for App { }, AppRoute::CreateUser => html! { -
- - -
+ }, AppRoute::Index | AppRoute::ListUsers => html! {
- {"Create a user"}
}, AppRoute::ListGroups => html! {
- //{"Create a user"}
}, + AppRoute::GroupDetails(group_id) => html! { + + }, AppRoute::UserDetails(username) => html! { -
- - -
+ }, AppRoute::ChangePassword(username) => html! { -
- - -
+ } } }) @@ -182,10 +175,9 @@ impl App { } fn view_banner(&self) -> Html { - if !self.is_admin() { - html!{} - } else { - html!{ + html! { + <> + {if self.is_admin() { html! { <>
- } + } } else { html!{} } } + {if self.user_info.is_some() { html! { + + }} else { html! {} }} + } } diff --git a/app/src/components/delete_group.rs b/app/src/components/delete_group.rs new file mode 100644 index 0000000..fb914cf --- /dev/null +++ b/app/src/components/delete_group.rs @@ -0,0 +1,158 @@ +use crate::{ + components::group_table::Group, + infra::{api::HostService, modal::Modal}, +}; +use anyhow::{Error, Result}; +use graphql_client::GraphQLQuery; +use yew::prelude::*; +use yew::services::fetch::FetchTask; +use yewtil::NeqAssign; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/delete_group.graphql", + response_derives = "Debug", + custom_scalars_module = "crate::infra::graphql" +)] +pub struct DeleteGroupQuery; + +pub struct DeleteGroup { + link: ComponentLink, + props: DeleteGroupProps, + node_ref: NodeRef, + modal: Option, + _task: Option, +} + +#[derive(yew::Properties, Clone, PartialEq, Debug)] +pub struct DeleteGroupProps { + pub group: Group, + pub on_group_deleted: Callback, + pub on_error: Callback, +} + +pub enum Msg { + ClickedDeleteGroup, + ConfirmDeleteGroup, + DismissModal, + DeleteGroupResponse(Result), +} + +impl Component for DeleteGroup { + type Message = Msg; + type Properties = DeleteGroupProps; + + fn create(props: Self::Properties, link: ComponentLink) -> Self { + Self { + link, + props, + node_ref: NodeRef::default(), + modal: None, + _task: None, + } + } + + fn rendered(&mut self, first_render: bool) { + if first_render { + self.modal = Some(Modal::new( + self.node_ref + .cast::() + .expect("Modal node is not an element"), + )); + } + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + match msg { + Msg::ClickedDeleteGroup => { + self.modal.as_ref().expect("modal not initialized").show(); + } + Msg::ConfirmDeleteGroup => { + self.update(Msg::DismissModal); + self._task = HostService::graphql_query::( + delete_group_query::Variables { + group_id: self.props.group.id, + }, + self.link.callback(Msg::DeleteGroupResponse), + "Error trying to delete group", + ) + .map_err(|e| self.props.on_error.emit(e)) + .ok(); + } + Msg::DismissModal => { + self.modal.as_ref().expect("modal not initialized").hide(); + } + Msg::DeleteGroupResponse(response) => { + if let Err(e) = response { + self.props.on_error.emit(e); + } else { + self.props.on_group_deleted.emit(self.props.group.id); + } + } + } + true + } + + fn change(&mut self, props: Self::Properties) -> ShouldRender { + self.props.neq_assign(props) + } + + fn view(&self) -> Html { + html! { + <> + + {self.show_modal()} + + } + } +} + +impl DeleteGroup { + fn show_modal(&self) -> Html { + html! { + + } + } +} diff --git a/app/src/components/group_details.rs b/app/src/components/group_details.rs new file mode 100644 index 0000000..b4c56c8 --- /dev/null +++ b/app/src/components/group_details.rs @@ -0,0 +1,236 @@ +use crate::{ + components::{ + add_group_member::{self, AddGroupMemberComponent}, + remove_user_from_group::RemoveUserFromGroupComponent, + router::{AppRoute, Link}, + }, + infra::api::HostService, +}; +use anyhow::{bail, Error, Result}; +use graphql_client::GraphQLQuery; +use yew::{ + prelude::*, + services::{fetch::FetchTask, ConsoleService}, +}; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/get_group_details.graphql", + response_derives = "Debug, Hash, PartialEq, Eq, Clone", + custom_scalars_module = "crate::infra::graphql" +)] +pub struct GetGroupDetails; + +pub type Group = get_group_details::GetGroupDetailsGroup; +pub type User = get_group_details::GetGroupDetailsGroupUsers; +pub type AddGroupMemberUser = add_group_member::User; + +pub struct GroupDetails { + link: ComponentLink, + props: Props, + /// The group info. If none, the error is in `error`. If `error` is None, then we haven't + /// received the server response yet. + group: Option, + /// Error message displayed to the user. + error: Option, + // Used to keep the request alive long enough. + _task: Option, +} + +/// State machine describing the possible transitions of the component state. +/// It starts out by fetching the user's details from the backend when loading. +pub enum Msg { + /// Received the group details response, either the group data or an error. + GroupDetailsResponse(Result), + OnError(Error), + OnUserAddedToGroup(AddGroupMemberUser), + OnUserRemovedFromGroup((String, i64)), +} + +#[derive(yew::Properties, Clone, PartialEq)] +pub struct Props { + pub group_id: i64, +} + +impl GroupDetails { + fn get_group_details(&mut self) { + self._task = HostService::graphql_query::( + get_group_details::Variables { + id: self.props.group_id, + }, + self.link.callback(Msg::GroupDetailsResponse), + "Error trying to fetch group details", + ) + .map_err(|e| { + ConsoleService::log(&e.to_string()); + e + }) + .ok(); + } + + fn handle_msg(&mut self, msg: ::Message) -> Result { + match msg { + Msg::GroupDetailsResponse(response) => match response { + Ok(group) => self.group = Some(group.group), + Err(e) => { + self.group = None; + bail!("Error getting user details: {}", e); + } + }, + Msg::OnError(e) => return Err(e), + Msg::OnUserAddedToGroup(user) => { + self.group.as_mut().unwrap().users.push(User { + id: user.id, + display_name: user.display_name, + }); + } + Msg::OnUserRemovedFromGroup((user_id, _)) => { + self.group + .as_mut() + .unwrap() + .users + .retain(|u| u.id != user_id); + } + } + Ok(true) + } + + fn view_messages(&self, error: &Option) -> Html { + if let Some(e) = error { + html! { +
+ {"Error: "}{e.to_string()} +
+ } + } else { + html! {} + } + } + + fn view_user_list(&self, g: &Group) -> Html { + let make_user_row = |user: &User| { + let user_id = user.id.clone(); + let display_name = user.display_name.clone(); + html! { + + + + {user_id.clone()} + + + {display_name} + + + + + } + }; + html! { +
+

{"Members"}

+
+ + + + + + + + + + {if g.users.is_empty() { + html! { + + + + } + } else { + html! {<>{g.users.iter().map(make_user_row).collect::>()}} + }} +
+
+ {self.view_add_user_button(g)} + + +
{"User Id"}{"Display name"}
{"No members"}
+
+
+ } + } + + fn view_add_user_button(&self, g: &Group) -> Html { + let users: Vec<_> = g + .users + .iter() + .map(|u| AddGroupMemberUser { + id: u.id.clone(), + display_name: u.display_name.clone(), + }) + .collect(); + html! { + + } + } +} + +impl Component for GroupDetails { + type Message = Msg; + type Properties = Props; + + fn create(props: Self::Properties, link: ComponentLink) -> Self { + let mut table = Self { + link, + props, + _task: None, + group: None, + error: None, + }; + table.get_group_details(); + table + } + + fn update(&mut self, msg: Self::Message) -> ShouldRender { + self.error = None; + match self.handle_msg(msg) { + Err(e) => { + ConsoleService::error(&e.to_string()); + self.error = Some(e); + true + } + Ok(b) => b, + } + } + + fn change(&mut self, _: Self::Properties) -> ShouldRender { + false + } + + fn view(&self) -> Html { + match (&self.group, &self.error) { + (None, None) => html! {{"Loading..."}}, + (None, Some(e)) => html! {
{"Error: "}{e.to_string()}
}, + (Some(u), error) => { + html! { +
+ /* + + */ + {self.view_user_list(u)} + {self.view_messages(error)} +
+ } + } + } + } +} diff --git a/app/src/components/group_table.rs b/app/src/components/group_table.rs index 7c0158b..4c20129 100644 --- a/app/src/components/group_table.rs +++ b/app/src/components/group_table.rs @@ -1,8 +1,8 @@ use crate::{ - //components::{ - // delete_group::DeleteGroup, - // router::{AppRoute, Link}, - //}, + components::{ + delete_group::DeleteGroup, + router::{AppRoute, Link}, + }, infra::api::HostService, }; use anyhow::{Error, Result}; @@ -14,14 +14,14 @@ use yew::services::{fetch::FetchTask, ConsoleService}; #[graphql( schema_path = "../schema.graphql", query_path = "queries/get_group_list.graphql", - response_derives = "Debug", + response_derives = "Debug,Clone,PartialEq", custom_scalars_module = "crate::infra::graphql" )] pub struct GetGroupList; use get_group_list::ResponseData; -type Group = get_group_list::GetGroupListGroups; +pub type Group = get_group_list::GetGroupListGroups; pub struct GroupTable { link: ComponentLink, @@ -116,9 +116,8 @@ impl GroupTable { - - - // + + @@ -136,16 +135,18 @@ impl GroupTable { fn view_group(&self, group: &Group) -> Html { html! { - - // - - - // + + + } } diff --git a/app/src/components/mod.rs b/app/src/components/mod.rs index a8c1d6a..192efe7 100644 --- a/app/src/components/mod.rs +++ b/app/src/components/mod.rs @@ -1,8 +1,11 @@ +pub mod add_group_member; pub mod add_user_to_group; pub mod app; pub mod change_password; pub mod create_user; +pub mod delete_group; pub mod delete_user; +pub mod group_details; pub mod group_table; pub mod login; pub mod logout; diff --git a/app/src/components/remove_user_from_group.rs b/app/src/components/remove_user_from_group.rs index d961508..d449cb2 100644 --- a/app/src/components/remove_user_from_group.rs +++ b/app/src/components/remove_user_from_group.rs @@ -1,4 +1,4 @@ -use crate::{components::user_details::Group, infra::api::HostService}; +use crate::infra::api::HostService; use anyhow::{Error, Result}; use graphql_client::GraphQLQuery; use yew::{ @@ -26,9 +26,8 @@ pub struct RemoveUserFromGroupComponent { #[derive(yew::Properties, Clone, PartialEq)] pub struct Props { pub username: String, - pub group: Group, - pub is_admin: bool, - pub on_user_removed_from_group: Callback, + pub group_id: i64, + pub on_user_removed_from_group: Callback<(String, i64)>, pub on_error: Callback, } @@ -39,7 +38,7 @@ pub enum Msg { impl RemoveUserFromGroupComponent { fn submit_remove_group(&mut self) -> Result { - let group = self.props.group.id; + let group = self.props.group_id; self._task = HostService::graphql_query::( remove_user_from_group::Variables { user: self.props.username.clone(), @@ -63,7 +62,7 @@ impl RemoveUserFromGroupComponent { response?; self.props .on_user_removed_from_group - .emit(self.props.group.clone()); + .emit((self.props.username.clone(), self.props.group_id)); } } Ok(true) @@ -97,21 +96,12 @@ impl Component for RemoveUserFromGroupComponent { } fn view(&self) -> Html { - let group = &self.props.group; html! { - <> - - { if self.props.is_admin { html! { - - }} else { html!{} } - } - + } } } diff --git a/app/src/components/router.rs b/app/src/components/router.rs index 82515a1..1c8f107 100644 --- a/app/src/components/router.rs +++ b/app/src/components/router.rs @@ -17,6 +17,8 @@ pub enum AppRoute { UserDetails(String), #[to = "/groups"] ListGroups, + #[to = "/group/{group_id}"] + GroupDetails(i64), #[to = "/"] Index, } diff --git a/app/src/components/user_details.rs b/app/src/components/user_details.rs index 4e8067f..840d95f 100644 --- a/app/src/components/user_details.rs +++ b/app/src/components/user_details.rs @@ -2,7 +2,7 @@ use crate::{ components::{ add_user_to_group::AddUserToGroupComponent, remove_user_from_group::RemoveUserFromGroupComponent, - router::{AppRoute, NavButton}, + router::{AppRoute, Link, NavButton}, user_details_form::UserDetailsForm, }, infra::api::HostService, @@ -45,7 +45,7 @@ pub enum Msg { UserDetailsResponse(Result), OnError(Error), OnUserAddedToGroup(Group), - OnUserRemovedFromGroup(Group), + OnUserRemovedFromGroup((String, i64)), } #[derive(yew::Properties, Clone, PartialEq)] @@ -83,8 +83,12 @@ impl UserDetails { Msg::OnUserAddedToGroup(group) => { self.user.as_mut().unwrap().groups.push(group); } - Msg::OnUserRemovedFromGroup(group) => { - self.user.as_mut().unwrap().groups.retain(|g| g != &group); + Msg::OnUserRemovedFromGroup((_, group_id)) => { + self.user + .as_mut() + .unwrap() + .groups + .retain(|g| g.id != group_id); } } Ok(true) @@ -107,12 +111,24 @@ impl UserDetails { let display_name = group.display_name.clone(); html! { - + {if self.props.is_admin { html! { + <> + + + + } } else { html! { + + } } } } };
{"Group ID"}{"Display name"}{"Delete"}{"Groups"}{"Delete"}
{&group.id}{&group.id}{&group.display_name} - // - //
+ + {&group.display_name} + + + +
{&group.display_name} - -
+ + {&group.display_name} + + + + {&group.display_name}