Skip to content

Commit 6505039

Browse files
committed
Add member to the group
1 parent e0ea8ec commit 6505039

File tree

7 files changed

+287
-2
lines changed

7 files changed

+287
-2
lines changed

site/src/AppRouter.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TemplateLayout } from "components/TemplateLayout/TemplateLayout"
66
import IndexPage from "pages"
77
import AuditPage from "pages/AuditPage/AuditPage"
88
import CreateGroupPage from "pages/GroupsPage/CreateGroupPage"
9+
import GroupPage from "pages/GroupsPage/GroupPage"
910
import GroupsPage from "pages/GroupsPage/GroupsPage"
1011
import LoginPage from "pages/LoginPage/LoginPage"
1112
import { SetupPage } from "pages/SetupPage/SetupPage"
@@ -168,6 +169,14 @@ export const AppRouter: FC = () => {
168169
</RequireAuth>
169170
}
170171
/>
172+
<Route
173+
path=":groupId"
174+
element={
175+
<AuthAndFrame>
176+
<GroupPage />
177+
</AuthAndFrame>
178+
}
179+
/>
171180
</Route>
172181

173182
<Route path="/audit">

site/src/api/api.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,16 @@ export const createGroup = async (
520520
const response = await axios.post(`/api/v2/organizations/${organizationId}/groups`, data)
521521
return response.data
522522
}
523+
524+
export const getGroup = async (groupId: string): Promise<TypesGen.Group> => {
525+
const response = await axios.get(`/api/v2/groups/${groupId}`)
526+
return response.data
527+
}
528+
529+
export const patchGroup = async (
530+
groupId: string,
531+
data: TypesGen.PatchGroupRequest,
532+
): Promise<TypesGen.Group> => {
533+
const response = await axios.patch(`/api/v2/groups/${groupId}`, data)
534+
return response.data
535+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import Table from "@material-ui/core/Table"
2+
import TableBody from "@material-ui/core/TableBody"
3+
import TableCell from "@material-ui/core/TableCell"
4+
import TableContainer from "@material-ui/core/TableContainer"
5+
import TableHead from "@material-ui/core/TableHead"
6+
import TableRow from "@material-ui/core/TableRow"
7+
import PersonAdd from "@material-ui/icons/PersonAdd"
8+
import { useMachine } from "@xstate/react"
9+
import { User } from "api/typesGenerated"
10+
import { AvatarData } from "components/AvatarData/AvatarData"
11+
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
12+
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
13+
import { LoadingButton } from "components/LoadingButton/LoadingButton"
14+
import { Margins } from "components/Margins/Margins"
15+
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
16+
import { Stack } from "components/Stack/Stack"
17+
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
18+
import { useState } from "react"
19+
import { Helmet } from "react-helmet-async"
20+
import { useParams } from "react-router-dom"
21+
import { pageTitle } from "util/page"
22+
import { groupMachine } from "xServices/groups/groupXService"
23+
24+
const AddGroupMember: React.FC<{
25+
isLoading: boolean
26+
onSubmit: (user: User, reset: () => void) => void
27+
}> = ({ isLoading, onSubmit }) => {
28+
const [selectedUser, setSelectedUser] = useState<User | null>(null)
29+
30+
const resetValues = () => {
31+
setSelectedUser(null)
32+
}
33+
34+
return (
35+
<form
36+
onSubmit={(e) => {
37+
e.preventDefault()
38+
39+
if (selectedUser) {
40+
onSubmit(selectedUser, resetValues)
41+
}
42+
}}
43+
>
44+
<Stack direction="row" alignItems="center" spacing={1}>
45+
<UserAutocomplete
46+
value={selectedUser}
47+
onChange={(newValue) => {
48+
setSelectedUser(newValue)
49+
}}
50+
/>
51+
52+
<LoadingButton
53+
disabled={!selectedUser}
54+
type="submit"
55+
size="small"
56+
startIcon={<PersonAdd />}
57+
loading={isLoading}
58+
>
59+
Add user
60+
</LoadingButton>
61+
</Stack>
62+
</form>
63+
)
64+
}
65+
66+
export const GroupPage: React.FC = () => {
67+
const { groupId } = useParams()
68+
if (!groupId) {
69+
throw new Error("groupId is not defined.")
70+
}
71+
72+
const [state, send] = useMachine(groupMachine, {
73+
context: {
74+
groupId,
75+
},
76+
})
77+
const { group } = state.context
78+
const isLoading = group === undefined
79+
80+
return (
81+
<>
82+
<Helmet>
83+
<title>{pageTitle(`${group?.name} · Group`)}</title>
84+
</Helmet>
85+
<ChooseOne>
86+
<Cond condition={isLoading}>
87+
<FullScreenLoader />
88+
</Cond>
89+
90+
<Cond condition>
91+
<Margins>
92+
<PageHeader>
93+
<PageHeaderTitle>{group?.name}</PageHeaderTitle>
94+
</PageHeader>
95+
96+
<Stack spacing={2.5}>
97+
<AddGroupMember
98+
isLoading={state.matches("addingMember")}
99+
onSubmit={(user, reset) => {
100+
send({ type: "ADD_MEMBER", userId: user.id, callback: reset })
101+
}}
102+
/>
103+
<TableContainer>
104+
<Table>
105+
<TableHead>
106+
<TableRow>
107+
<TableCell width="99%">User</TableCell>
108+
<TableCell width="1%"></TableCell>
109+
</TableRow>
110+
</TableHead>
111+
112+
<TableBody>
113+
{group?.members.map((member) => (
114+
<TableRow key={member.id}>
115+
<TableCell width="99%">
116+
<AvatarData
117+
title={member.username}
118+
subtitle={member.email}
119+
highlightTitle
120+
/>
121+
</TableCell>
122+
<TableCell width="1%"></TableCell>
123+
</TableRow>
124+
))}
125+
</TableBody>
126+
</Table>
127+
</TableContainer>
128+
</Stack>
129+
</Margins>
130+
</Cond>
131+
</ChooseOne>
132+
</>
133+
)
134+
}
135+
136+
export default GroupPage

site/src/pages/GroupsPage/GroupsPage.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import TableHead from "@material-ui/core/TableHead"
99
import TableRow from "@material-ui/core/TableRow"
1010
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
1111
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
12+
import AvatarGroup from "@material-ui/lab/AvatarGroup"
1213
import { useMachine } from "@xstate/react"
1314
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
1415
import { EmptyState } from "components/EmptyState/EmptyState"
1516
import { Margins } from "components/Margins/Margins"
1617
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
1718
import { TableCellLink } from "components/TableCellLink/TableCellLink"
1819
import { TableLoader } from "components/TableLoader/TableLoader"
20+
import { UserAvatar } from "components/UserAvatar/UserAvatar"
1921
import { useOrganizationId } from "hooks/useOrganizationId"
2022
import React from "react"
2123
import { Helmet } from "react-helmet-async"
@@ -99,7 +101,18 @@ export const GroupsPage: React.FC = () => {
99101
>
100102
<TableCellLink to={groupPageLink}>{group.name}</TableCellLink>
101103

102-
<TableCell>Users</TableCell>
104+
<TableCell>
105+
{group.members.length === 0 && "No members"}
106+
<AvatarGroup>
107+
{group.members.map((member) => (
108+
<UserAvatar
109+
key={member.username}
110+
username={member.username}
111+
avatarURL={member.avatar_url}
112+
/>
113+
))}
114+
</AvatarGroup>
115+
</TableCell>
103116

104117
<TableCellLink to={groupPageLink}>
105118
<div className={styles.arrowCell}>

site/src/testHelpers/entities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ export const MockAuditLog2: TypesGen.AuditLog = {
848848

849849
export const MockGroup: TypesGen.Group = {
850850
name: "Coder Group",
851-
uuid: "53bded77-7b9d-4e82-8771-991a34d75930",
851+
id: "53bded77-7b9d-4e82-8771-991a34d75930",
852852
organization_id: MockOrganization.id,
853853
members: [],
854854
}

site/src/testHelpers/handlers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,16 @@ export const handlers = [
179179
rest.get("/api/v2/organizations/:organizationId/groups", (req, res, ctx) => {
180180
return res(ctx.status(200), ctx.json([MockGroup]))
181181
}),
182+
183+
rest.post("/api/v2/organizations/:organizationId/groups", async (req, res, ctx) => {
184+
return res(ctx.status(201), ctx.json(M.MockGroup))
185+
}),
186+
187+
rest.get("/api/v2/groups/:groupId", (req, res, ctx) => {
188+
return res(ctx.status(200), ctx.json(MockGroup))
189+
}),
190+
191+
rest.patch("/api/v2/groups/:groupId", (req, res, ctx) => {
192+
return res(ctx.status(200), ctx.json(MockGroup))
193+
}),
182194
]
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { getGroup, patchGroup } from "api/api"
2+
import { getErrorMessage } from "api/errors"
3+
import { Group } from "api/typesGenerated"
4+
import { displayError } from "components/GlobalSnackbar/utils"
5+
import { assign, createMachine } from "xstate"
6+
7+
export const groupMachine = createMachine(
8+
{
9+
id: "group",
10+
schema: {
11+
context: {} as {
12+
groupId: string
13+
group?: Group
14+
addMemberCallback?: () => void
15+
},
16+
services: {} as {
17+
loadGroup: {
18+
data: Group
19+
}
20+
addMember: {
21+
data: Group
22+
}
23+
},
24+
events: {} as {
25+
type: "ADD_MEMBER"
26+
userId: string
27+
callback: () => void
28+
},
29+
},
30+
tsTypes: {} as import("./groupXService.typegen").Typegen0,
31+
initial: "loading",
32+
states: {
33+
loading: {
34+
invoke: {
35+
src: "loadGroup",
36+
onDone: {
37+
actions: ["assignGroup"],
38+
target: "idle",
39+
},
40+
onError: {
41+
actions: ["displayLoadGroupError"],
42+
target: "idle",
43+
},
44+
},
45+
},
46+
idle: {
47+
on: {
48+
ADD_MEMBER: {
49+
target: "addingMember",
50+
actions: ["assignAddMemberCallback"],
51+
},
52+
},
53+
},
54+
addingMember: {
55+
invoke: {
56+
src: "addMember",
57+
onDone: {
58+
actions: ["assignGroup", "callAddMemberCallback"],
59+
target: "idle",
60+
},
61+
onError: {
62+
target: "idle",
63+
actions: ["displayAddMemberError"],
64+
},
65+
},
66+
},
67+
},
68+
},
69+
{
70+
services: {
71+
loadGroup: ({ groupId }) => getGroup(groupId),
72+
addMember: ({ group }, { userId }) => {
73+
if (!group) {
74+
throw new Error("Group not defined.")
75+
}
76+
77+
return patchGroup(group.id, { name: "", add_users: [userId], remove_users: [] })
78+
},
79+
},
80+
actions: {
81+
assignGroup: assign({
82+
group: (_, { data }) => data,
83+
}),
84+
assignAddMemberCallback: assign({
85+
addMemberCallback: (_, { callback }) => callback,
86+
}),
87+
displayLoadGroupError: (_, { data }) => {
88+
const message = getErrorMessage(data, "Failed to the group.")
89+
displayError(message)
90+
},
91+
displayAddMemberError: (_, { data }) => {
92+
const message = getErrorMessage(data, "Failed to add member to the group.")
93+
displayError(message)
94+
},
95+
callAddMemberCallback: ({ addMemberCallback }) => {
96+
if (addMemberCallback) {
97+
addMemberCallback()
98+
}
99+
},
100+
},
101+
},
102+
)

0 commit comments

Comments
 (0)