mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
chore(site): refactor groups to use react-query (#9701)
This commit is contained in:
@ -969,6 +969,24 @@ export const patchGroup = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const addMember = async (groupId: string, userId: string) => {
|
||||
return patchGroup(groupId, {
|
||||
name: "",
|
||||
display_name: "",
|
||||
add_users: [userId],
|
||||
remove_users: [],
|
||||
});
|
||||
};
|
||||
|
||||
export const removeMember = async (groupId: string, userId: string) => {
|
||||
return patchGroup(groupId, {
|
||||
name: "",
|
||||
display_name: "",
|
||||
add_users: [],
|
||||
remove_users: [userId],
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteGroup = async (groupId: string): Promise<void> => {
|
||||
await axios.delete(`/api/v2/groups/${groupId}`);
|
||||
};
|
||||
|
101
site/src/api/queries/groups.ts
Normal file
101
site/src/api/queries/groups.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import * as API from "api/api";
|
||||
import { checkAuthorization } from "api/api";
|
||||
import {
|
||||
CreateGroupRequest,
|
||||
Group,
|
||||
PatchGroupRequest,
|
||||
} from "api/typesGenerated";
|
||||
|
||||
const GROUPS_QUERY_KEY = ["groups"];
|
||||
|
||||
const getGroupQueryKey = (groupId: string) => ["group", groupId];
|
||||
|
||||
export const groups = (organizationId: string) => {
|
||||
return {
|
||||
queryKey: GROUPS_QUERY_KEY,
|
||||
queryFn: () => API.getGroups(organizationId),
|
||||
};
|
||||
};
|
||||
|
||||
export const group = (groupId: string) => {
|
||||
return {
|
||||
queryKey: getGroupQueryKey(groupId),
|
||||
queryFn: () => API.getGroup(groupId),
|
||||
};
|
||||
};
|
||||
|
||||
export const groupPermissions = (groupId: string) => {
|
||||
return {
|
||||
queryKey: [...getGroupQueryKey(groupId), "permissions"],
|
||||
queryFn: () =>
|
||||
checkAuthorization({
|
||||
checks: {
|
||||
canUpdateGroup: {
|
||||
object: {
|
||||
resource_type: "group",
|
||||
resource_id: groupId,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const createGroup = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: ({
|
||||
organizationId,
|
||||
...request
|
||||
}: CreateGroupRequest & { organizationId: string }) =>
|
||||
API.createGroup(organizationId, request),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(GROUPS_QUERY_KEY);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const patchGroup = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: ({
|
||||
groupId,
|
||||
...request
|
||||
}: PatchGroupRequest & { groupId: string }) =>
|
||||
API.patchGroup(groupId, request),
|
||||
onSuccess: async (updatedGroup: Group) =>
|
||||
invalidateGroup(queryClient, updatedGroup.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteGroup = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: API.deleteGroup,
|
||||
onSuccess: async (_: void, groupId: string) =>
|
||||
invalidateGroup(queryClient, groupId),
|
||||
};
|
||||
};
|
||||
|
||||
export const addMember = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
||||
API.addMember(groupId, userId),
|
||||
onSuccess: async (updatedGroup: Group) =>
|
||||
invalidateGroup(queryClient, updatedGroup.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const removeMember = (queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
||||
API.removeMember(groupId, userId),
|
||||
onSuccess: async (updatedGroup: Group) =>
|
||||
invalidateGroup(queryClient, updatedGroup.id),
|
||||
};
|
||||
};
|
||||
|
||||
export const invalidateGroup = (queryClient: QueryClient, groupId: string) =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries(GROUPS_QUERY_KEY),
|
||||
queryClient.invalidateQueries(getGroupQueryKey(groupId)),
|
||||
]);
|
@ -1,26 +1,17 @@
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { useOrganizationId } from "hooks/useOrganizationId";
|
||||
import { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { createGroupMachine } from "xServices/groups/createGroupXService";
|
||||
import CreateGroupPageView from "./CreateGroupPageView";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createGroup } from "api/queries/groups";
|
||||
|
||||
export const CreateGroupPage: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const organizationId = useOrganizationId();
|
||||
const [createState, sendCreateEvent] = useMachine(createGroupMachine, {
|
||||
context: {
|
||||
organizationId,
|
||||
},
|
||||
actions: {
|
||||
onCreate: (_, { data }) => {
|
||||
navigate(`/groups/${data.id}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { error } = createState.context;
|
||||
const createGroupMutation = useMutation(createGroup(queryClient));
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -28,14 +19,15 @@ export const CreateGroupPage: FC = () => {
|
||||
<title>{pageTitle("Create Group")}</title>
|
||||
</Helmet>
|
||||
<CreateGroupPageView
|
||||
onSubmit={(data) => {
|
||||
sendCreateEvent({
|
||||
type: "CREATE",
|
||||
data,
|
||||
onSubmit={async (data) => {
|
||||
const newGroup = await createGroupMutation.mutateAsync({
|
||||
organizationId,
|
||||
...data,
|
||||
});
|
||||
navigate(`/groups/${newGroup.id}`);
|
||||
}}
|
||||
formErrors={error}
|
||||
isLoading={createState.matches("creatingGroup")}
|
||||
formErrors={createGroupMutation.error}
|
||||
isLoading={createGroupMutation.isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -9,8 +9,7 @@ import TableRow from "@mui/material/TableRow";
|
||||
import DeleteOutline from "@mui/icons-material/DeleteOutline";
|
||||
import PersonAdd from "@mui/icons-material/PersonAdd";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { User } from "api/typesGenerated";
|
||||
import { Group, User } from "api/typesGenerated";
|
||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
@ -30,7 +29,6 @@ import { useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { groupMachine } from "xServices/groups/groupXService";
|
||||
import { Maybe } from "components/Conditionals/Maybe";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import {
|
||||
@ -39,6 +37,175 @@ import {
|
||||
} from "components/TableToolbar/TableToolbar";
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||
import { isEveryoneGroup } from "utils/groups";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
addMember,
|
||||
deleteGroup,
|
||||
group,
|
||||
groupPermissions,
|
||||
removeMember,
|
||||
} from "api/queries/groups";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
|
||||
export const GroupPage: React.FC = () => {
|
||||
const { groupId } = useParams() as { groupId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const groupQuery = useQuery(group(groupId));
|
||||
const groupData = groupQuery.data;
|
||||
const { data: permissions } = useQuery(groupPermissions(groupId));
|
||||
const addMemberMutation = useMutation(addMember(queryClient));
|
||||
const deleteGroupMutation = useMutation(deleteGroup(queryClient));
|
||||
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
|
||||
const isLoading = !groupData || !permissions;
|
||||
const canUpdateGroup = permissions ? permissions.canUpdateGroup : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle(
|
||||
(groupData?.display_name || groupData?.name) ?? "Loading...",
|
||||
)}
|
||||
</title>
|
||||
</Helmet>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<Loader />
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Maybe condition={canUpdateGroup}>
|
||||
<Link to="settings" component={RouterLink}>
|
||||
<Button startIcon={<SettingsOutlined />}>Settings</Button>
|
||||
</Link>
|
||||
<Button
|
||||
disabled={groupData?.id === groupData?.organization_id}
|
||||
onClick={() => {
|
||||
setIsDeletingGroup(true);
|
||||
}}
|
||||
startIcon={<DeleteOutline />}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Maybe>
|
||||
}
|
||||
>
|
||||
<PageHeaderTitle>
|
||||
{groupData?.display_name || groupData?.name}
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderSubtitle>
|
||||
{/* Show the name if it differs from the display name. */}
|
||||
{groupData?.display_name &&
|
||||
groupData?.display_name !== groupData?.name
|
||||
? groupData?.name
|
||||
: ""}{" "}
|
||||
</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Maybe
|
||||
condition={
|
||||
canUpdateGroup &&
|
||||
groupData !== undefined &&
|
||||
!isEveryoneGroup(groupData)
|
||||
}
|
||||
>
|
||||
<AddGroupMember
|
||||
isLoading={addMemberMutation.isLoading}
|
||||
onSubmit={async (user, reset) => {
|
||||
try {
|
||||
await addMemberMutation.mutateAsync({
|
||||
groupId,
|
||||
userId: user.id,
|
||||
});
|
||||
reset();
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to add member."),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Maybe>
|
||||
<TableToolbar>
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={groupData?.members.length ?? 0}
|
||||
total={groupData?.members.length ?? 0}
|
||||
label="members"
|
||||
/>
|
||||
</TableToolbar>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="99%">User</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond
|
||||
condition={Boolean(groupData?.members.length === 0)}
|
||||
>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No members yet"
|
||||
description="Add a member using the controls above"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{groupData?.members.map((member) => (
|
||||
<GroupMemberRow
|
||||
member={member}
|
||||
group={groupData}
|
||||
key={member.id}
|
||||
canUpdate={canUpdateGroup}
|
||||
/>
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</Margins>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
|
||||
{group && (
|
||||
<DeleteDialog
|
||||
isOpen={isDeletingGroup}
|
||||
confirmLoading={deleteGroupMutation.isLoading}
|
||||
name={group.name}
|
||||
entity="group"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteGroupMutation.mutateAsync(groupId);
|
||||
navigate("/groups");
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to delete group."));
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeletingGroup(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddGroupMember: React.FC<{
|
||||
isLoading: boolean;
|
||||
@ -83,182 +250,56 @@ const AddGroupMember: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupPage: React.FC = () => {
|
||||
const { groupId } = useParams();
|
||||
if (!groupId) {
|
||||
throw new Error("groupId is not defined.");
|
||||
}
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [state, send] = useMachine(groupMachine, {
|
||||
context: {
|
||||
groupId,
|
||||
},
|
||||
actions: {
|
||||
redirectToGroups: () => {
|
||||
navigate("/groups");
|
||||
},
|
||||
},
|
||||
});
|
||||
const { group, permissions } = state.context;
|
||||
const isLoading = group === undefined || permissions === undefined;
|
||||
const canUpdateGroup = permissions ? permissions.canUpdateGroup : false;
|
||||
const GroupMemberRow = (props: {
|
||||
member: User;
|
||||
group: Group;
|
||||
canUpdate: boolean;
|
||||
}) => {
|
||||
const { member, group, canUpdate } = props;
|
||||
const queryClient = useQueryClient();
|
||||
const removeMemberMutation = useMutation(removeMember(queryClient));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{pageTitle((group?.display_name || group?.name) ?? "Loading...")}
|
||||
</title>
|
||||
</Helmet>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<Loader />
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Maybe condition={canUpdateGroup}>
|
||||
<Link to="settings" component={RouterLink}>
|
||||
<Button startIcon={<SettingsOutlined />}>Settings</Button>
|
||||
</Link>
|
||||
<Button
|
||||
disabled={group?.id === group?.organization_id}
|
||||
onClick={() => {
|
||||
send("DELETE");
|
||||
}}
|
||||
startIcon={<DeleteOutline />}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Maybe>
|
||||
}
|
||||
>
|
||||
<PageHeaderTitle>
|
||||
{group?.display_name || group?.name}
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderSubtitle>
|
||||
{/* Show the name if it differs from the display name. */}
|
||||
{group?.display_name && group?.display_name !== group?.name
|
||||
? group?.name
|
||||
: ""}{" "}
|
||||
</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Maybe
|
||||
condition={
|
||||
canUpdateGroup &&
|
||||
group !== undefined &&
|
||||
!isEveryoneGroup(group)
|
||||
}
|
||||
>
|
||||
<AddGroupMember
|
||||
isLoading={state.matches("addingMember")}
|
||||
onSubmit={(user, reset) => {
|
||||
send({
|
||||
type: "ADD_MEMBER",
|
||||
userId: user.id,
|
||||
callback: reset,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Maybe>
|
||||
<TableToolbar>
|
||||
<PaginationStatus
|
||||
isLoading={Boolean(isLoading)}
|
||||
showing={group?.members.length ?? 0}
|
||||
total={group?.members.length ?? 0}
|
||||
label="members"
|
||||
/>
|
||||
</TableToolbar>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="99%">User</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={Boolean(group?.members.length === 0)}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No members yet"
|
||||
description="Add a member using the controls above"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{group?.members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell width="99%">
|
||||
<AvatarData
|
||||
avatar={
|
||||
<UserAvatar
|
||||
username={member.username}
|
||||
avatarURL={member.avatar_url}
|
||||
/>
|
||||
}
|
||||
title={member.username}
|
||||
subtitle={member.email}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell width="1%">
|
||||
<Maybe condition={canUpdateGroup}>
|
||||
<TableRowMenu
|
||||
data={member}
|
||||
menuItems={[
|
||||
{
|
||||
label: "Remove",
|
||||
onClick: () => {
|
||||
send({
|
||||
type: "REMOVE_MEMBER",
|
||||
userId: member.id,
|
||||
});
|
||||
},
|
||||
disabled:
|
||||
group.id === group.organization_id,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Maybe>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</Margins>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
|
||||
{group && (
|
||||
<DeleteDialog
|
||||
isOpen={state.matches("confirmingDelete")}
|
||||
confirmLoading={state.matches("deleting")}
|
||||
name={group.name}
|
||||
entity="group"
|
||||
onConfirm={() => {
|
||||
send("CONFIRM_DELETE");
|
||||
}}
|
||||
onCancel={() => {
|
||||
send("CANCEL_DELETE");
|
||||
}}
|
||||
<TableRow key={member.id}>
|
||||
<TableCell width="99%">
|
||||
<AvatarData
|
||||
avatar={
|
||||
<UserAvatar
|
||||
username={member.username}
|
||||
avatarURL={member.avatar_url}
|
||||
/>
|
||||
}
|
||||
title={member.username}
|
||||
subtitle={member.email}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</TableCell>
|
||||
<TableCell width="1%">
|
||||
<Maybe condition={canUpdate}>
|
||||
<TableRowMenu
|
||||
data={member}
|
||||
menuItems={[
|
||||
{
|
||||
label: "Remove",
|
||||
onClick: async () => {
|
||||
try {
|
||||
await removeMemberMutation.mutateAsync({
|
||||
groupId: group.id,
|
||||
userId: member.id,
|
||||
});
|
||||
displaySuccess("Member removed successfully.");
|
||||
} catch (error) {
|
||||
displayError(
|
||||
getErrorMessage(error, "Failed to remove member."),
|
||||
);
|
||||
}
|
||||
},
|
||||
disabled: group.id === group.organization_id,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Maybe>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,24 +1,28 @@
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
|
||||
import { useOrganizationId } from "hooks/useOrganizationId";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import { FC } from "react";
|
||||
import { FC, useEffect } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { groupsMachine } from "xServices/groups/groupsXService";
|
||||
import GroupsPageView from "./GroupsPageView";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { groups } from "api/queries/groups";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
|
||||
export const GroupsPage: FC = () => {
|
||||
const organizationId = useOrganizationId();
|
||||
const { createGroup: canCreateGroup } = usePermissions();
|
||||
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility();
|
||||
const [state] = useMachine(groupsMachine, {
|
||||
context: {
|
||||
organizationId,
|
||||
shouldFetchGroups: isTemplateRBACEnabled,
|
||||
},
|
||||
});
|
||||
const { groups } = state.context;
|
||||
const groupsQuery = useQuery(groups(organizationId));
|
||||
|
||||
useEffect(() => {
|
||||
if (groupsQuery.error) {
|
||||
displayError(
|
||||
getErrorMessage(groupsQuery.error, "Error on loading groups."),
|
||||
);
|
||||
}
|
||||
}, [groupsQuery.error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -27,7 +31,7 @@ export const GroupsPage: FC = () => {
|
||||
</Helmet>
|
||||
|
||||
<GroupsPageView
|
||||
groups={groups}
|
||||
groups={groupsQuery.data}
|
||||
canCreateGroup={canCreateGroup}
|
||||
isTemplateRBACEnabled={isTemplateRBACEnabled}
|
||||
/>
|
||||
|
@ -1,33 +1,24 @@
|
||||
import { useMachine } from "@xstate/react";
|
||||
import { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { editGroupMachine } from "xServices/groups/editGroupXService";
|
||||
import SettingsGroupPageView from "./SettingsGroupPageView";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { group, patchGroup } from "api/queries/groups";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
|
||||
export const SettingsGroupPage: FC = () => {
|
||||
const { groupId } = useParams();
|
||||
if (!groupId) {
|
||||
throw new Error("Group ID not defined.");
|
||||
}
|
||||
|
||||
const { groupId } = useParams() as { groupId: string };
|
||||
const queryClient = useQueryClient();
|
||||
const groupQuery = useQuery(group(groupId));
|
||||
const patchGroupMutation = useMutation(patchGroup(queryClient));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToGroup = () => {
|
||||
navigate(`/groups/${groupId}`);
|
||||
};
|
||||
|
||||
const [editState, sendEditEvent] = useMachine(editGroupMachine, {
|
||||
context: {
|
||||
groupId,
|
||||
},
|
||||
actions: {
|
||||
onUpdate: navigateToGroup,
|
||||
},
|
||||
});
|
||||
const { error, group } = editState.context;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@ -36,13 +27,23 @@ export const SettingsGroupPage: FC = () => {
|
||||
|
||||
<SettingsGroupPageView
|
||||
onCancel={navigateToGroup}
|
||||
onSubmit={(data) => {
|
||||
sendEditEvent({ type: "UPDATE", data });
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
await patchGroupMutation.mutateAsync({
|
||||
groupId,
|
||||
...data,
|
||||
add_users: [],
|
||||
remove_users: [],
|
||||
});
|
||||
navigateToGroup();
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to update group"));
|
||||
}
|
||||
}}
|
||||
group={group}
|
||||
formErrors={error}
|
||||
isLoading={editState.matches("loading")}
|
||||
isUpdating={editState.matches("updating")}
|
||||
group={groupQuery.data}
|
||||
formErrors={groupQuery.error}
|
||||
isLoading={groupQuery.isLoading}
|
||||
isUpdating={patchGroupMutation.isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -1,59 +0,0 @@
|
||||
import { createGroup } from "api/api";
|
||||
import { CreateGroupRequest, Group } from "api/typesGenerated";
|
||||
import { createMachine, assign } from "xstate";
|
||||
|
||||
export const createGroupMachine = createMachine(
|
||||
{
|
||||
id: "createGroupMachine",
|
||||
schema: {
|
||||
context: {} as {
|
||||
organizationId: string;
|
||||
error?: unknown;
|
||||
},
|
||||
services: {} as {
|
||||
createGroup: {
|
||||
data: Group;
|
||||
};
|
||||
},
|
||||
events: {} as {
|
||||
type: "CREATE";
|
||||
data: CreateGroupRequest;
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./createGroupXService.typegen").Typegen0,
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
CREATE: {
|
||||
target: "creatingGroup",
|
||||
},
|
||||
},
|
||||
},
|
||||
creatingGroup: {
|
||||
invoke: {
|
||||
src: "createGroup",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["onCreate"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
createGroup: ({ organizationId }, { data }) =>
|
||||
createGroup(organizationId, data),
|
||||
},
|
||||
actions: {
|
||||
assignError: assign({
|
||||
error: (_, event) => event.data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
@ -1,100 +0,0 @@
|
||||
import { getGroup, patchGroup } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { Group } from "api/typesGenerated";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { assign, createMachine } from "xstate";
|
||||
|
||||
export const editGroupMachine = createMachine(
|
||||
{
|
||||
id: "editGroup",
|
||||
schema: {
|
||||
context: {} as {
|
||||
groupId: string;
|
||||
group?: Group;
|
||||
error?: unknown;
|
||||
},
|
||||
services: {} as {
|
||||
loadGroup: {
|
||||
data: Group;
|
||||
};
|
||||
updateGroup: {
|
||||
data: Group;
|
||||
};
|
||||
},
|
||||
events: {} as {
|
||||
type: "UPDATE";
|
||||
data: {
|
||||
display_name: string;
|
||||
name: string;
|
||||
avatar_url: string;
|
||||
quota_allowance: number;
|
||||
};
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./editGroupXService.typegen").Typegen0,
|
||||
initial: "loading",
|
||||
states: {
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "loadGroup",
|
||||
onDone: {
|
||||
actions: ["assignGroup"],
|
||||
target: "idle",
|
||||
},
|
||||
onError: {
|
||||
actions: ["displayLoadGroupError"],
|
||||
target: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
idle: {
|
||||
on: {
|
||||
UPDATE: {
|
||||
target: "updating",
|
||||
},
|
||||
},
|
||||
},
|
||||
updating: {
|
||||
invoke: {
|
||||
src: "updateGroup",
|
||||
onDone: {
|
||||
actions: ["onUpdate"],
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["assignError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
loadGroup: ({ groupId }) => getGroup(groupId),
|
||||
|
||||
updateGroup: ({ group }, { data }) => {
|
||||
if (!group) {
|
||||
throw new Error("Group not defined.");
|
||||
}
|
||||
|
||||
return patchGroup(group.id, {
|
||||
...data,
|
||||
add_users: [],
|
||||
remove_users: [],
|
||||
});
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
assignGroup: assign({
|
||||
group: (_, { data }) => data,
|
||||
}),
|
||||
displayLoadGroupError: (_, { data }) => {
|
||||
const message = getErrorMessage(data, "Failed to the group.");
|
||||
displayError(message);
|
||||
},
|
||||
assignError: assign({
|
||||
error: (_, event) => event.data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
@ -1,275 +0,0 @@
|
||||
import { deleteGroup, getGroup, patchGroup, checkAuthorization } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { AuthorizationResponse, Group } from "api/typesGenerated";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { assign, createMachine } from "xstate";
|
||||
|
||||
export const groupMachine = createMachine(
|
||||
{
|
||||
id: "group",
|
||||
schema: {
|
||||
context: {} as {
|
||||
groupId: string;
|
||||
group?: Group;
|
||||
permissions?: AuthorizationResponse;
|
||||
addMemberCallback?: () => void;
|
||||
removingMember?: string;
|
||||
},
|
||||
services: {} as {
|
||||
loadGroup: {
|
||||
data: Group;
|
||||
};
|
||||
loadPermissions: {
|
||||
data: AuthorizationResponse;
|
||||
};
|
||||
addMember: {
|
||||
data: Group;
|
||||
};
|
||||
removeMember: {
|
||||
data: Group;
|
||||
};
|
||||
deleteGroup: {
|
||||
data: unknown;
|
||||
};
|
||||
},
|
||||
events: {} as
|
||||
| {
|
||||
type: "ADD_MEMBER";
|
||||
userId: string;
|
||||
callback: () => void;
|
||||
}
|
||||
| {
|
||||
type: "REMOVE_MEMBER";
|
||||
userId: string;
|
||||
}
|
||||
| {
|
||||
type: "DELETE";
|
||||
}
|
||||
| {
|
||||
type: "CONFIRM_DELETE";
|
||||
}
|
||||
| {
|
||||
type: "CANCEL_DELETE";
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./groupXService.typegen").Typegen0,
|
||||
initial: "loading",
|
||||
states: {
|
||||
loading: {
|
||||
type: "parallel",
|
||||
states: {
|
||||
data: {
|
||||
initial: "loading",
|
||||
states: {
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "loadGroup",
|
||||
onDone: {
|
||||
actions: ["assignGroup"],
|
||||
target: "success",
|
||||
},
|
||||
onError: {
|
||||
actions: ["displayLoadGroupError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
initial: "loading",
|
||||
states: {
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "loadPermissions",
|
||||
onDone: {
|
||||
actions: ["assignPermissions"],
|
||||
target: "success",
|
||||
},
|
||||
onError: {
|
||||
actions: ["displayLoadPermissionsError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onDone: "idle",
|
||||
},
|
||||
idle: {
|
||||
on: {
|
||||
ADD_MEMBER: {
|
||||
target: "addingMember",
|
||||
actions: ["assignAddMemberCallback"],
|
||||
},
|
||||
REMOVE_MEMBER: {
|
||||
target: "removingMember",
|
||||
actions: ["removeUserFromMembers"],
|
||||
},
|
||||
DELETE: {
|
||||
target: "confirmingDelete",
|
||||
},
|
||||
},
|
||||
},
|
||||
addingMember: {
|
||||
invoke: {
|
||||
src: "addMember",
|
||||
onDone: {
|
||||
actions: ["assignGroup", "callAddMemberCallback"],
|
||||
target: "idle",
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["displayAddMemberError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
removingMember: {
|
||||
invoke: {
|
||||
src: "removeMember",
|
||||
onDone: {
|
||||
actions: ["assignGroup", "displayRemoveMemberSuccess"],
|
||||
target: "idle",
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["displayRemoveMemberError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
confirmingDelete: {
|
||||
on: {
|
||||
CONFIRM_DELETE: "deleting",
|
||||
CANCEL_DELETE: "idle",
|
||||
},
|
||||
},
|
||||
deleting: {
|
||||
invoke: {
|
||||
src: "deleteGroup",
|
||||
onDone: {
|
||||
actions: ["redirectToGroups"],
|
||||
},
|
||||
onError: {
|
||||
actions: ["displayDeleteGroupError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
loadGroup: ({ groupId }) => getGroup(groupId),
|
||||
loadPermissions: ({ groupId }) =>
|
||||
checkAuthorization({
|
||||
checks: {
|
||||
canUpdateGroup: {
|
||||
object: {
|
||||
resource_type: "group",
|
||||
resource_id: groupId,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
},
|
||||
}),
|
||||
addMember: ({ group }, { userId }) => {
|
||||
if (!group) {
|
||||
throw new Error("Group not defined.");
|
||||
}
|
||||
|
||||
return patchGroup(group.id, {
|
||||
name: "",
|
||||
display_name: "",
|
||||
add_users: [userId],
|
||||
remove_users: [],
|
||||
});
|
||||
},
|
||||
removeMember: ({ group }, { userId }) => {
|
||||
if (!group) {
|
||||
throw new Error("Group not defined.");
|
||||
}
|
||||
|
||||
return patchGroup(group.id, {
|
||||
name: "",
|
||||
display_name: "",
|
||||
add_users: [],
|
||||
remove_users: [userId],
|
||||
});
|
||||
},
|
||||
deleteGroup: ({ group }) => {
|
||||
if (!group) {
|
||||
throw new Error("Group not defined.");
|
||||
}
|
||||
|
||||
return deleteGroup(group.id);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
assignGroup: assign({
|
||||
group: (_, { data }) => data,
|
||||
}),
|
||||
assignAddMemberCallback: assign({
|
||||
addMemberCallback: (_, { callback }) => callback,
|
||||
}),
|
||||
displayLoadGroupError: (_, { data }) => {
|
||||
const message = getErrorMessage(data, "Failed to load the group.");
|
||||
displayError(message);
|
||||
},
|
||||
displayAddMemberError: (_, { data }) => {
|
||||
const message = getErrorMessage(
|
||||
data,
|
||||
"Failed to add member to the group.",
|
||||
);
|
||||
displayError(message);
|
||||
},
|
||||
callAddMemberCallback: ({ addMemberCallback }) => {
|
||||
if (addMemberCallback) {
|
||||
addMemberCallback();
|
||||
}
|
||||
},
|
||||
// Optimistically remove the user from members
|
||||
removeUserFromMembers: assign({
|
||||
group: ({ group }, { userId }) => {
|
||||
if (!group) {
|
||||
throw new Error("Group is not defined.");
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
members: group.members.filter(
|
||||
(currentMember) => currentMember.id !== userId,
|
||||
),
|
||||
};
|
||||
},
|
||||
}),
|
||||
displayRemoveMemberError: (_, { data }) => {
|
||||
const message = getErrorMessage(
|
||||
data,
|
||||
"Failed to remove member from the group.",
|
||||
);
|
||||
displayError(message);
|
||||
},
|
||||
displayRemoveMemberSuccess: () => {
|
||||
displaySuccess("Member removed successfully.");
|
||||
},
|
||||
displayDeleteGroupError: (_, { data }) => {
|
||||
const message = getErrorMessage(data, "Failed to delete group.");
|
||||
displayError(message);
|
||||
},
|
||||
assignPermissions: assign({
|
||||
permissions: (_, { data }) => data,
|
||||
}),
|
||||
displayLoadPermissionsError: (_, { data }) => {
|
||||
const message = getErrorMessage(
|
||||
data,
|
||||
"Failed to load the permissions.",
|
||||
);
|
||||
displayError(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
@ -1,60 +0,0 @@
|
||||
import { getGroups } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { Group } from "api/typesGenerated";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { assign, createMachine } from "xstate";
|
||||
|
||||
export const groupsMachine = createMachine(
|
||||
{
|
||||
id: "groupsMachine",
|
||||
predictableActionArguments: true,
|
||||
schema: {
|
||||
context: {} as {
|
||||
organizationId: string;
|
||||
shouldFetchGroups: boolean;
|
||||
groups?: Group[];
|
||||
},
|
||||
services: {} as {
|
||||
loadGroups: {
|
||||
data: Group[];
|
||||
};
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./groupsXService.typegen").Typegen0,
|
||||
initial: "loading",
|
||||
states: {
|
||||
loading: {
|
||||
always: [{ target: "idle", cond: "cantFetchGroups" }],
|
||||
invoke: {
|
||||
src: "loadGroups",
|
||||
onDone: {
|
||||
actions: ["assignGroups"],
|
||||
target: "idle",
|
||||
},
|
||||
onError: {
|
||||
target: "idle",
|
||||
actions: ["displayLoadingGroupsError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
idle: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
guards: {
|
||||
cantFetchGroups: ({ shouldFetchGroups }) => !shouldFetchGroups,
|
||||
},
|
||||
services: {
|
||||
loadGroups: ({ organizationId }) => getGroups(organizationId),
|
||||
},
|
||||
actions: {
|
||||
assignGroups: assign({
|
||||
groups: (_, { data }) => data,
|
||||
}),
|
||||
displayLoadingGroupsError: (_, { data }) => {
|
||||
const message = getErrorMessage(data, "Error on loading groups.");
|
||||
displayError(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
Reference in New Issue
Block a user