Skip to content

Commit 5638c4d

Browse files
authored
♻️ Refactor new invitation (#244)
1 parent 8dc8281 commit 5638c4d

File tree

7 files changed

+145
-58
lines changed

7 files changed

+145
-58
lines changed

frontend/src/components/Invitations/Invitations.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Box,
44
Button,
55
Container,
6+
Divider,
67
Flex,
78
Skeleton,
89
Table,
@@ -12,6 +13,7 @@ import {
1213
Th,
1314
Thead,
1415
Tr,
16+
useDisclosure,
1517
} from "@chakra-ui/react"
1618
import { useQuery, useQueryClient } from "@tanstack/react-query"
1719
import { useEffect, useState } from "react"
@@ -20,6 +22,7 @@ import { ErrorBoundary } from "react-error-boundary"
2022
import { InvitationsService } from "../../client/services"
2123
import { Route } from "../../routes/_layout/$team"
2224
import CancelInvitation from "./CancelInvitation"
25+
import NewInvitation from "./NewInvitation"
2326

2427
const PER_PAGE = 5
2528

@@ -148,9 +151,16 @@ function InvitationsTable() {
148151
}
149152

150153
function Invitations() {
154+
const { isOpen, onOpen, onClose } = useDisclosure()
155+
151156
return (
152-
<Container maxW="full">
157+
<Container maxW="full" p={0}>
153158
<InvitationsTable />
159+
<Divider my={4} />
160+
<Button variant="secondary" onClick={onOpen} mb={4}>
161+
New Invitation
162+
</Button>
163+
<NewInvitation isOpen={isOpen} onClose={onClose} />
154164
</Container>
155165
)
156166
}

frontend/src/components/Invitations/NewInvitation.tsx

Lines changed: 117 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,40 @@
11
import {
2-
Box,
32
Button,
4-
Flex,
53
FormControl,
6-
FormErrorMessage,
74
FormLabel,
85
Input,
6+
Modal,
7+
ModalBody,
8+
ModalCloseButton,
9+
ModalContent,
10+
ModalFooter,
11+
ModalHeader,
12+
ModalOverlay,
913
Text,
1014
} from "@chakra-ui/react"
1115
import { useMutation, useQueryClient } from "@tanstack/react-query"
1216
import { type SubmitHandler, useForm } from "react-hook-form"
1317

14-
import { type InvitationCreate, InvitationsService } from "../../client"
15-
import useCustomToast from "../../hooks/useCustomToast"
18+
import { useState } from "react"
19+
import {
20+
type ApiError,
21+
type InvitationCreate,
22+
InvitationsService,
23+
} from "../../client"
1624
import { Route } from "../../routes/_layout/$team"
17-
import { emailPattern, handleError } from "../../utils"
25+
import { emailPattern, extractErrorMessage } from "../../utils"
26+
27+
interface NewInvitationProps {
28+
isOpen: boolean
29+
onClose: () => void
30+
}
1831

19-
const NewInvitation = () => {
32+
const NewInvitation = ({ isOpen, onClose }: NewInvitationProps) => {
33+
const [status, setStatus] = useState<
34+
"idle" | "loading" | "success" | "error"
35+
>("idle")
2036
const { team: teamSlug } = Route.useParams()
2137
const queryClient = useQueryClient()
22-
const showToast = useCustomToast()
2338
const {
2439
register,
2540
handleSubmit,
@@ -34,16 +49,19 @@ const NewInvitation = () => {
3449
mutationFn: (data: InvitationCreate) =>
3550
InvitationsService.createInvitation({ requestBody: data }),
3651
onSuccess: () => {
37-
showToast("Success!", "Invitation sent successfully.", "success")
52+
setStatus("success")
3853
reset()
3954
},
40-
onError: handleError.bind(showToast),
55+
onError: () => {
56+
setStatus("error")
57+
},
4158
onSettled: () => {
4259
queryClient.invalidateQueries({ queryKey: ["invitations"] })
4360
},
4461
})
4562

4663
const onSubmit: SubmitHandler<InvitationCreate> = (data) => {
64+
setStatus("loading")
4765
const updatedData: InvitationCreate = {
4866
...data,
4967
role: "member",
@@ -52,43 +70,101 @@ const NewInvitation = () => {
5270
mutation.mutate(updatedData)
5371
}
5472

73+
const handleClose = () => {
74+
setStatus("idle")
75+
onClose()
76+
}
77+
5578
return (
56-
<Flex
57-
textAlign={{ base: "center", md: "left" }}
58-
flexDirection="column"
59-
alignItems="center"
60-
justifyContent="center"
79+
<Modal
80+
isOpen={isOpen}
81+
onClose={handleClose}
82+
size={{ base: "sm", md: "md" }}
83+
isCentered
6184
>
62-
<Box
85+
<ModalOverlay />
86+
<ModalContent
6387
as="form"
6488
onSubmit={handleSubmit(onSubmit)}
6589
data-testid="new-invitation"
6690
>
67-
<Text mb={4}>Invite someone to join your team.</Text>
68-
<FormControl isRequired isInvalid={!!errors.email}>
69-
<FormLabel htmlFor="email" hidden>
70-
Email address
71-
</FormLabel>
72-
<Input
73-
id="email"
74-
{...register("email", {
75-
required: "Email is required",
76-
pattern: emailPattern,
77-
})}
78-
placeholder="Email address"
79-
type="text"
80-
w="auto"
81-
data-testid="invitation-email"
82-
/>
83-
{errors.email && (
84-
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
85-
)}
86-
</FormControl>
87-
<Button variant="primary" type="submit" isLoading={isSubmitting} mt={4}>
88-
Send invitation
89-
</Button>
90-
</Box>
91-
</Flex>
91+
{status === "idle" || status === "loading" ? (
92+
<>
93+
<ModalHeader>Team Invitation</ModalHeader>
94+
<ModalCloseButton aria-label="Close invitation modal" />
95+
<ModalBody>
96+
<Text mb={4}>Invite someone to your team</Text>
97+
<FormControl isRequired isInvalid={!!errors.email}>
98+
<FormLabel htmlFor="email" hidden>
99+
Email address
100+
</FormLabel>
101+
<Input
102+
id="email"
103+
{...register("email", {
104+
required: "Email is required",
105+
pattern: emailPattern,
106+
})}
107+
placeholder="Email address"
108+
type="text"
109+
data-testid="invitation-email"
110+
/>
111+
{errors.email && (
112+
<Text id="email-error" color="red.500" mt={2}>
113+
{errors.email.message}
114+
</Text>
115+
)}
116+
</FormControl>
117+
</ModalBody>
118+
<ModalFooter>
119+
<Button
120+
variant="secondary"
121+
type="submit"
122+
isLoading={isSubmitting}
123+
mt={4}
124+
>
125+
Send invitation
126+
</Button>
127+
</ModalFooter>
128+
</>
129+
) : status === "success" ? (
130+
<>
131+
<ModalHeader>Success!</ModalHeader>
132+
<ModalCloseButton aria-label="Close invitation modal" />
133+
<ModalBody>
134+
<Text>
135+
The invitation has been sent to <b>{mutation.data?.email}</b>{" "}
136+
successfully. Now they just need to accept it.
137+
</Text>
138+
</ModalBody>
139+
<ModalFooter>
140+
<Button onClick={handleClose} mt={4}>
141+
Ok
142+
</Button>
143+
</ModalFooter>
144+
</>
145+
) : (
146+
<>
147+
<ModalHeader>Error</ModalHeader>
148+
<ModalCloseButton aria-label="Close invitation modal" />
149+
<ModalBody>
150+
<Text>
151+
An error occurred while sending the invitation. Please try
152+
again.
153+
</Text>
154+
155+
<Text color="red.500" mt={2}>
156+
{extractErrorMessage(mutation.error as ApiError)}
157+
</Text>
158+
</ModalBody>
159+
<ModalFooter>
160+
<Button onClick={handleClose} mt={4}>
161+
Ok
162+
</Button>
163+
</ModalFooter>
164+
</>
165+
)}
166+
</ModalContent>
167+
</Modal>
92168
)
93169
}
94170

frontend/src/components/TeamSettings/TeamInformation.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Container, Flex, Text } from "@chakra-ui/react"
1+
import { Container } from "@chakra-ui/react"
22
import {
33
useMutation,
44
useQueryClient,
@@ -13,7 +13,6 @@ import { getCurrentUserRole, handleError } from "../../utils"
1313
import CustomCard from "../Common/CustomCard"
1414
import EditableField from "../Common/EditableField"
1515
import Invitations from "../Invitations/Invitations"
16-
import NewInvitation from "../Invitations/NewInvitation"
1716
import Team from "../Teams/Team"
1817
import DeleteTeam from "./DeleteTeam"
1918
import TransferTeam from "./TransferTeam"
@@ -58,14 +57,6 @@ const TeamInformation = () => {
5857
<>
5958
<CustomCard title="Team Invitations">
6059
<Invitations />
61-
<Box boxShadow="xs" px={8} py={4} borderRadius="lg" mb={8}>
62-
<Text fontWeight="bold" mb={4}>
63-
New Invitation
64-
</Text>
65-
<Flex>
66-
<NewInvitation />
67-
</Flex>
68-
</Box>
6960
</CustomCard>
7061

7162
<CustomCard title="Transfer Ownership">

frontend/src/utils.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,15 @@ export function getCurrentUserRole(
6666
return team.user_links.find(({ user }) => user.id === currentUser?.id)?.role
6767
}
6868

69-
export const handleError = function (this: any, err: ApiError) {
69+
export function extractErrorMessage(err: ApiError): string {
7070
const errDetail = (err.body as any)?.detail
71-
let errorMessage = errDetail || "Something went wrong."
7271
if (Array.isArray(errDetail) && errDetail.length > 0) {
73-
errorMessage = errDetail[0].msg
72+
return errDetail[0].msg
7473
}
74+
return errDetail || "Something went wrong."
75+
}
76+
77+
export const handleError = function (this: any, err: ApiError) {
78+
const errorMessage = extractErrorMessage(err)
7579
this("Error", errorMessage, "error")
7680
}

frontend/tests/invitations.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ test.describe("User with role admin can manage team invitations", () => {
2121
await createTeam(page, teamName)
2222
await sendInvitation(page, teamSlug, email)
2323

24-
await expect(page.getByText("Invitation sent successfully")).toBeVisible()
24+
await expect(
25+
page.getByText(`The invitation has been sent to ${email} successfully`),
26+
).toBeVisible()
2527
})
2628

2729
test("User can see the invitation in the invitations list", async ({
@@ -33,6 +35,7 @@ test.describe("User with role admin can manage team invitations", () => {
3335

3436
await createTeam(page, teamName)
3537
await sendInvitation(page, teamSlug, email)
38+
await page.getByRole("button", { name: "Ok" }).click()
3639

3740
await expect(page.getByRole("cell", { name: email })).toBeVisible()
3841
await expect(page.getByRole("cell", { name: "pending" })).toBeVisible()
@@ -45,6 +48,7 @@ test.describe("User with role admin can manage team invitations", () => {
4548

4649
await createTeam(page, teamName)
4750
await sendInvitation(page, teamSlug, email)
51+
await page.getByRole("button", { name: "Ok" }).click()
4852

4953
await page.getByTestId("cancel-invitation").click()
5054
await expect(page.getByText("The invitation was cancelled")).toBeVisible()
@@ -112,6 +116,7 @@ test.describe("User can accept invitations to a team", () => {
112116
// user 1 sends an invitation to user 2
113117
await page.goto(`/${teamSlug}`)
114118
await sendInvitation(page, teamSlug, user2Email)
119+
await page.getByRole("button", { name: "Ok" }).click()
115120
await logOutUser(page, teamName)
116121

117122
// user 2 logs in and accepts the invitation
@@ -171,6 +176,7 @@ test.describe("Different scenarios for viewing invitations", () => {
171176
// user 1 sends an invitation to user 2
172177
await page.goto(`/${teamSlug}`)
173178
await sendInvitation(page, teamSlug, user2Email)
179+
await page.getByRole("button", { name: "Ok" }).click()
174180
await logOutUser(page, teamName)
175181

176182
// view invitation logged out
@@ -199,6 +205,7 @@ test.describe("Different scenarios for viewing invitations", () => {
199205
// user 1 sends an invitation to user 2
200206
await page.goto(`/${teamSlug}`)
201207
await sendInvitation(page, teamSlug, user2Email)
208+
await page.getByRole("button", { name: "Ok" }).click()
202209
await logOutUser(page, teamName)
203210

204211
// user 3 logs in and tries to view the invitation

frontend/tests/new-team.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { expect, test } from "@playwright/test"
22
import { randomTeamName } from "./utils/random"
33

4-
54
test("New team is visible", async ({ page }) => {
65
await page.goto("/teams/new")
76
await expect(page.getByRole("heading", { name: "New Team" })).toBeVisible()

frontend/tests/utils/userUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export async function sendInvitation(
7676
email: string,
7777
) {
7878
await page.goto(`/${teamSlug}/settings`)
79-
await expect(page.getByTestId("new-invitation")).toBeVisible()
79+
await page.getByRole("button", { name: "New Invitation" }).click()
8080
await page.getByTestId("invitation-email").fill(email)
8181
await page.getByRole("button", { name: "Send invitation" }).click()
8282
}

0 commit comments

Comments
 (0)