app: Implement group management

Except group creation
This commit is contained in:
Valentin Tolmer 2021-10-11 18:41:55 +02:00 committed by nitnelave
parent 42da86cf72
commit 8bd1dec180
12 changed files with 694 additions and 69 deletions

View file

@ -0,0 +1,5 @@
mutation DeleteGroupQuery($groupId: Int!) {
deleteGroup(groupId: $groupId) {
ok
}
}

View file

@ -0,0 +1,10 @@
query GetGroupDetails($id: Int!) {
group(groupId: $id) {
id
displayName
users {
id
displayName
}
}
}

View file

@ -8,3 +8,9 @@ query ListUsersQuery($filters: RequestFilter) {
creationDate
}
}
query ListUserNames($filters: RequestFilter) {
users(filters: $filters) {
id
displayName
}
}

View file

@ -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<Self>,
props: Props,
/// The list of existing users, initially not loaded.
user_list: Option<Vec<User>>,
/// The currently selected user.
selected_user: Option<User>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
}
pub enum Msg {
UserListResponse(Result<list_user_names::ResponseData>),
SubmitAddMember,
AddMemberResponse(Result<add_user_to_group::ResponseData>),
SelectionChanged(Option<SelectOptionProps>),
}
#[derive(yew::Properties, Clone, PartialEq)]
pub struct Props {
pub group_id: i64,
pub users: Vec<User>,
pub on_user_added_to_group: Callback<User>,
pub on_error: Callback<Error>,
}
impl AddGroupMemberComponent {
fn get_user_list(&mut self) {
self._task = HostService::graphql_query::<ListUserNames>(
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<bool> {
let user_id = match self.selected_user.clone() {
None => return Ok(false),
Some(user) => user.id,
};
self._task = HostService::graphql_query::<AddUserToGroup>(
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: <Self as Component>::Message) -> Result<bool> {
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<User> {
let user_groups = self.props.users.iter().collect::<HashSet<_>>();
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>) -> 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! {
<SelectOption value=user.id.clone() text=user.display_name.clone() key=user.id />
}
};
html! {
<>
<td>
<Select on_selection_change=self.link.callback(Msg::SelectionChanged)>
{
to_add_user_list
.into_iter()
.map(make_select_option)
.collect::<Vec<_>>()
}
</Select>
</td>
<td>
<button
class="btn btn-success"
onclick=self.link.callback(|_| Msg::SubmitAddMember)>
{"Add"}
</button>
</td>
</>
}
} else {
html! {
<>
<td>{"Loading groups"}</td>
<td></td>
</>
}
}
}
}

View file

@ -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 {
<LoginForm on_logged_in=link.callback(Msg::Login)/>
},
AppRoute::CreateUser => html! {
<div>
<LogoutButton on_logged_out=link.callback(|_| Msg::Logout) />
<CreateUserForm/>
</div>
<CreateUserForm/>
},
AppRoute::Index | AppRoute::ListUsers => html! {
<div>
<LogoutButton on_logged_out=link.callback(|_| Msg::Logout) />
<UserTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
</div>
},
AppRoute::ListGroups => html! {
<div>
<LogoutButton on_logged_out=link.callback(|_| Msg::Logout) />
<GroupTable />
//<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
</div>
},
AppRoute::GroupDetails(group_id) => html! {
<GroupDetails group_id=group_id />
},
AppRoute::UserDetails(username) => html! {
<div>
<LogoutButton on_logged_out=link.callback(|_| Msg::Logout) />
<UserDetails username=username.clone() is_admin=is_admin />
</div>
<UserDetails username=username.clone() is_admin=is_admin />
},
AppRoute::ChangePassword(username) => html! {
<div>
<LogoutButton on_logged_out=link.callback(|_| Msg::Logout) />
<ChangePasswordForm username=username.clone() is_admin=is_admin />
</div>
<ChangePasswordForm username=username.clone() is_admin=is_admin />
}
}
})
@ -182,10 +175,9 @@ impl App {
}
fn view_banner(&self) -> Html {
if !self.is_admin() {
html!{}
} else {
html!{
html! {
<>
{if self.is_admin() { html! {
<>
<div>
<NavButton
@ -202,7 +194,11 @@ impl App {
</NavButton>
</div>
</>
}
} } else { html!{} } }
{if self.user_info.is_some() { html! {
<LogoutButton on_logged_out=self.link.callback(|_| Msg::Logout) />
}} else { html! {} }}
</>
}
}

View file

@ -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<Self>,
props: DeleteGroupProps,
node_ref: NodeRef,
modal: Option<Modal>,
_task: Option<FetchTask>,
}
#[derive(yew::Properties, Clone, PartialEq, Debug)]
pub struct DeleteGroupProps {
pub group: Group,
pub on_group_deleted: Callback<i64>,
pub on_error: Callback<Error>,
}
pub enum Msg {
ClickedDeleteGroup,
ConfirmDeleteGroup,
DismissModal,
DeleteGroupResponse(Result<delete_group_query::ResponseData>),
}
impl Component for DeleteGroup {
type Message = Msg;
type Properties = DeleteGroupProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> 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::<web_sys::Element>()
.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::<DeleteGroupQuery>(
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! {
<>
<button
class="btn btn-danger"
onclick=self.link.callback(|_| Msg::ClickedDeleteGroup)>
<i class="bi-x-circle-fill" aria-label="Delete group" />
</button>
{self.show_modal()}
</>
}
}
}
impl DeleteGroup {
fn show_modal(&self) -> Html {
html! {
<div
class="modal fade"
id="exampleModal".to_string() + &self.props.group.id.to_string()
tabindex="-1"
aria-labelledby="exampleModalLabel"
aria-hidden="true"
ref=self.node_ref.clone()>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">{"Delete group?"}</h5>
<button
type="button"
class="btn-close"
aria-label="Close"
onclick=self.link.callback(|_| Msg::DismissModal) />
</div>
<div class="modal-body">
<span>
{"Are you sure you want to delete group "}
<b>{&self.props.group.display_name}</b>{"?"}
</span>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick=self.link.callback(|_| Msg::DismissModal)>
{"Cancel"}
</button>
<button
type="button"
onclick=self.link.callback(|_| Msg::ConfirmDeleteGroup)
class="btn btn-danger">{"Yes, I'm sure"}</button>
</div>
</div>
</div>
</div>
}
}
}

View file

@ -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<Self>,
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<Group>,
/// Error message displayed to the user.
error: Option<Error>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
}
/// 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<get_group_details::ResponseData>),
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::<GetGroupDetails>(
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: <Self as Component>::Message) -> Result<bool> {
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<Error>) -> Html {
if let Some(e) = error {
html! {
<div class="alert alert-danger">
<span>{"Error: "}{e.to_string()}</span>
</div>
}
} 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! {
<tr>
<td>
<Link route=AppRoute::UserDetails(user_id.clone())>
{user_id.clone()}
</Link>
</td>
<td>{display_name}</td>
<td>
<RemoveUserFromGroupComponent
username=user_id
group_id=g.id
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
on_error=self.link.callback(Msg::OnError)/>
</td>
</tr>
}
};
html! {
<div>
<h3>{"Members"}</h3>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr key="headerRow">
<th>{"User Id"}</th>
<th>{"Display name"}</th>
<th></th>
</tr>
</thead>
<tbody>
{if g.users.is_empty() {
html! {
<tr key="EmptyRow">
<td>{"No members"}</td>
</tr>
}
} else {
html! {<>{g.users.iter().map(make_user_row).collect::<Vec<_>>()}</>}
}}
<hr/>
<tr key="groupToAddRow">
{self.view_add_user_button(g)}
</tr>
</tbody>
</table>
</div>
</div>
}
}
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! {
<AddGroupMemberComponent
group_id=g.id
users=users
on_error=self.link.callback(Msg::OnError)
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
}
}
}
impl Component for GroupDetails {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> 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! {<div>{"Error: "}{e.to_string()}</div>},
(Some(u), error) => {
html! {
<div>
/*
<GroupDetailsForm
user=u.clone()
on_error=self.link.callback(Msg::OnError)/>
*/
{self.view_user_list(u)}
{self.view_messages(error)}
</div>
}
}
}
}
}

View file

@ -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<Self>,
@ -116,9 +116,8 @@ impl GroupTable {
<table class="table table-striped">
<thead>
<tr>
<th>{"Group ID"}</th>
<th>{"Display name"}</th>
//<th>{"Delete"}</th>
<th>{"Groups"}</th>
<th>{"Delete"}</th>
</tr>
</thead>
<tbody>
@ -136,16 +135,18 @@ impl GroupTable {
fn view_group(&self, group: &Group) -> Html {
html! {
<tr key=group.id.clone()>
//<td><Link route=AppRoute::GroupDetails(group.id.clone())>{&group.id}</Link></td>
<td>{&group.id}</td>
<td>{&group.display_name}</td>
//<td>
// <DeleteGroup
// groupname=group.id.clone()
// on_group_deleted=self.link.callback(Msg::OnGroupDeleted)
// on_error=self.link.callback(Msg::OnError)/>
//</td>
<tr key=group.id>
<td>
<Link route=AppRoute::GroupDetails(group.id)>
{&group.display_name}
</Link>
</td>
<td>
<DeleteGroup
group=group.clone()
on_group_deleted=self.link.callback(Msg::OnGroupDeleted)
on_error=self.link.callback(Msg::OnError)/>
</td>
</tr>
}
}

View file

@ -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;

View file

@ -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<Group>,
pub group_id: i64,
pub on_user_removed_from_group: Callback<(String, i64)>,
pub on_error: Callback<Error>,
}
@ -39,7 +38,7 @@ pub enum Msg {
impl RemoveUserFromGroupComponent {
fn submit_remove_group(&mut self) -> Result<bool> {
let group = self.props.group.id;
let group = self.props.group_id;
self._task = HostService::graphql_query::<RemoveUserFromGroup>(
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! {
<>
<td>{&group.display_name}</td>
{ if self.props.is_admin { html! {
<td>
<button
class="btn btn-danger"
onclick=self.link.callback(|_| Msg::SubmitRemoveGroup)>
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
</button>
</td>
}} else { html!{} }
}
</>
<button
class="btn btn-danger"
onclick=self.link.callback(|_| Msg::SubmitRemoveGroup)>
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
</button>
}
}
}

View file

@ -17,6 +17,8 @@ pub enum AppRoute {
UserDetails(String),
#[to = "/groups"]
ListGroups,
#[to = "/group/{group_id}"]
GroupDetails(i64),
#[to = "/"]
Index,
}

View file

@ -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<get_user_details::ResponseData>),
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! {
<tr key="groupRow_".to_string() + &display_name>
<RemoveUserFromGroupComponent
username=u.id.clone()
group=group.clone()
is_admin=self.props.is_admin
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
on_error=self.link.callback(Msg::OnError)/>
{if self.props.is_admin { html! {
<>
<td>
<Link route=AppRoute::GroupDetails(group.id)>
{&group.display_name}
</Link>
</td>
<td>
<RemoveUserFromGroupComponent
username=u.id.clone()
group_id=group.id
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
on_error=self.link.callback(Msg::OnError)/>
</td>
</>
} } else { html! {
<td>{&group.display_name}</td>
} } }
</tr>
}
};