Skip to content

Commit f911c8a

Browse files
feat: Add suspend user action (#1275)
1 parent 34b91fd commit f911c8a

File tree

13 files changed

+23635
-31
lines changed

13 files changed

+23635
-31
lines changed

site/jest.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import "@testing-library/jest-dom"
12
import { server } from "./src/testHelpers/server"
23

34
// Establish API mocking before all tests through MSW.

site/package-lock.json

Lines changed: 23318 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"@storybook/addon-essentials": "6.4.22",
5959
"@storybook/addon-links": "6.4.22",
6060
"@storybook/react": "6.4.22",
61+
"@testing-library/jest-dom": "5.16.4",
6162
"@testing-library/react": "12.1.5",
6263
"@testing-library/user-event": "14.1.1",
6364
"@types/express": "4.17.13",

site/src/api/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export const getApiKey = async (): Promise<Types.APIKeyResponse> => {
7676
}
7777

7878
export const getUsers = async (): Promise<TypesGen.User[]> => {
79-
const response = await axios.get<TypesGen.User[]>("/api/v2/users?offset=0&limit=1000")
79+
const response = await axios.get<TypesGen.User[]>("/api/v2/users?status=active")
8080
return response.data
8181
}
8282

@@ -135,3 +135,8 @@ export const updateProfile = async (userId: string, data: Types.UpdateProfileReq
135135
const response = await axios.put(`/api/v2/users/${userId}/profile`, data)
136136
return response.data
137137
}
138+
139+
export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen.User> => {
140+
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/suspend`)
141+
return response.data
142+
}

site/src/components/GlobalSnackbar/utils.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { displaySuccess, isNotificationTextPrefixed, MsgType, NotificationMsg } from "./utils"
1+
import {
2+
displayError,
3+
displaySuccess,
4+
isNotificationTextPrefixed,
5+
MsgType,
6+
NotificationMsg,
7+
SnackbarEventType,
8+
} from "./utils"
29

310
describe("Snackbar", () => {
411
describe("isNotificationTextPrefixed", () => {
@@ -76,4 +83,18 @@ describe("Snackbar", () => {
7683
expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual(expected)
7784
})
7885
})
86+
87+
describe("displayError", () => {
88+
it("shows the title and the message", (done) => {
89+
const message = "Some error happened"
90+
91+
window.addEventListener(SnackbarEventType, (event) => {
92+
const notificationEvent = event as CustomEvent<NotificationMsg>
93+
expect(notificationEvent.detail.msg).toEqual(message)
94+
done()
95+
})
96+
97+
displayError(message)
98+
})
99+
})
79100
})

site/src/components/GlobalSnackbar/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,7 @@ export const displayMsg = (msg: string, additionalMsg?: string): void => {
6060
export const displaySuccess = (msg: string, additionalMsg?: string): void => {
6161
dispatchNotificationEvent(MsgType.Success, msg, additionalMsg ? [additionalMsg] : undefined)
6262
}
63+
64+
export const displayError = (msg: string, additionalMsg?: string): void => {
65+
dispatchNotificationEvent(MsgType.Error, msg, additionalMsg ? [additionalMsg] : undefined)
66+
}

site/src/components/UsersTable/UsersTable.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Column, Table } from "../Table/Table"
55
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
66
import { UserCell } from "../UserCell/UserCell"
77

8-
const Language = {
8+
export const Language = {
99
pageTitle: "Users",
1010
usersTitle: "All users",
1111
emptyMessage: "No users found",
@@ -27,9 +27,10 @@ const columns: Column<UserResponse>[] = [
2727

2828
export interface UsersTableProps {
2929
users: UserResponse[]
30+
onSuspendUser: (user: UserResponse) => void
3031
}
3132

32-
export const UsersTable: React.FC<UsersTableProps> = ({ users }) => {
33+
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser }) => {
3334
return (
3435
<Table
3536
columns={columns}
@@ -42,9 +43,7 @@ export const UsersTable: React.FC<UsersTableProps> = ({ users }) => {
4243
menuItems={[
4344
{
4445
label: Language.suspendMenuItem,
45-
onClick: () => {
46-
// TO-DO: Add suspend action here
47-
},
46+
onClick: onSuspendUser,
4847
},
4948
]}
5049
/>
Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,93 @@
1-
import { screen } from "@testing-library/react"
1+
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
22
import React from "react"
3-
import { render } from "../../testHelpers"
4-
import { UsersPage } from "./UsersPage"
3+
import * as API from "../../api"
4+
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
5+
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
6+
import { MockUser, MockUser2, render } from "../../testHelpers"
7+
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
8+
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"
9+
10+
const suspendUser = async (setupActionSpies: () => void) => {
11+
// Get the first user in the table
12+
const users = await screen.findAllByText(/.*@coder.com/)
13+
const firstUserRow = users[0].closest("tr")
14+
if (!firstUserRow) {
15+
throw new Error("Error on get the first user row")
16+
}
17+
18+
// Click on the "more" button to display the "Suspend" option
19+
const moreButton = within(firstUserRow).getByLabelText("more")
20+
fireEvent.click(moreButton)
21+
const menu = screen.getByRole("menu")
22+
const suspendButton = within(menu).getByText(UsersTableLanguage.suspendMenuItem)
23+
fireEvent.click(suspendButton)
24+
25+
// Check if the confirm message is displayed
26+
const confirmDialog = screen.getByRole("dialog")
27+
expect(confirmDialog).toHaveTextContent(`${UsersPageLanguage.suspendDialogMessagePrefix} ${MockUser.username}?`)
28+
29+
// Setup spies to check the actions after
30+
setupActionSpies()
31+
32+
// Click on the "Confirm" button
33+
const confirmButton = within(confirmDialog).getByText(UsersPageLanguage.suspendDialogAction)
34+
fireEvent.click(confirmButton)
35+
}
536

637
describe("Users Page", () => {
738
it("shows users", async () => {
839
render(<UsersPage />)
940
const users = await screen.findAllByText(/.*@coder.com/)
1041
expect(users.length).toEqual(2)
1142
})
43+
44+
describe("suspend user", () => {
45+
describe("when it is success", () => {
46+
it("shows a success message and refresh the page", async () => {
47+
render(
48+
<>
49+
<UsersPage />
50+
<GlobalSnackbar />
51+
</>,
52+
)
53+
54+
await suspendUser(() => {
55+
jest.spyOn(API, "suspendUser").mockResolvedValueOnce(MockUser)
56+
jest.spyOn(API, "getUsers").mockImplementationOnce(() => Promise.resolve([MockUser, MockUser2]))
57+
})
58+
59+
// Check if the success message is displayed
60+
await screen.findByText(usersXServiceLanguage.suspendUserSuccess)
61+
62+
// Check if the API was called correctly
63+
expect(API.suspendUser).toBeCalledTimes(1)
64+
expect(API.suspendUser).toBeCalledWith(MockUser.id)
65+
66+
// Check if the users list was reload
67+
await waitFor(() => expect(API.getUsers).toBeCalledTimes(1))
68+
})
69+
})
70+
71+
describe("when it fails", () => {
72+
it("shows an error message", async () => {
73+
render(
74+
<>
75+
<UsersPage />
76+
<GlobalSnackbar />
77+
</>,
78+
)
79+
80+
await suspendUser(() => {
81+
jest.spyOn(API, "suspendUser").mockRejectedValueOnce({})
82+
})
83+
84+
// Check if the success message is displayed
85+
await screen.findByText(usersXServiceLanguage.suspendUserError)
86+
87+
// Check if the API was called correctly
88+
expect(API.suspendUser).toBeCalledTimes(1)
89+
expect(API.suspendUser).toBeCalledWith(MockUser.id)
90+
})
91+
})
92+
})
1293
})
Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import { useActor } from "@xstate/react"
22
import React, { useContext, useEffect } from "react"
33
import { useNavigate } from "react-router"
4-
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
4+
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
55
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
66
import { XServiceContext } from "../../xServices/StateContext"
77
import { UsersPageView } from "./UsersPageView"
88

9+
export const Language = {
10+
suspendDialogTitle: "Suspend user",
11+
suspendDialogAction: "Suspend",
12+
suspendDialogMessagePrefix: "Do you want to suspend the user",
13+
}
14+
915
export const UsersPage: React.FC = () => {
1016
const xServices = useContext(XServiceContext)
1117
const [usersState, usersSend] = useActor(xServices.usersXService)
12-
const { users, getUsersError } = usersState.context
18+
const { users, getUsersError, userIdToSuspend } = usersState.context
1319
const navigate = useNavigate()
20+
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
1421

1522
/**
1623
* Fetch users on component mount
@@ -19,20 +26,42 @@ export const UsersPage: React.FC = () => {
1926
usersSend("GET_USERS")
2027
}, [usersSend])
2128

22-
if (usersState.matches("error")) {
23-
return <ErrorSummary error={getUsersError} />
24-
}
25-
2629
if (!users) {
2730
return <FullScreenLoader />
2831
} else {
2932
return (
30-
<UsersPageView
31-
users={users}
32-
openUserCreationDialog={() => {
33-
navigate("/users/create")
34-
}}
35-
/>
33+
<>
34+
<UsersPageView
35+
users={users}
36+
openUserCreationDialog={() => {
37+
navigate("/users/create")
38+
}}
39+
onSuspendUser={(user) => {
40+
usersSend({ type: "SUSPEND_USER", userId: user.id })
41+
}}
42+
error={getUsersError}
43+
/>
44+
45+
<ConfirmDialog
46+
type="delete"
47+
hideCancel={false}
48+
open={usersState.matches("confirmUserSuspension")}
49+
confirmLoading={usersState.matches("suspendingUser")}
50+
title={Language.suspendDialogTitle}
51+
confirmText={Language.suspendDialogAction}
52+
onConfirm={() => {
53+
usersSend("CONFIRM_USER_SUSPENSION")
54+
}}
55+
onClose={() => {
56+
usersSend("CANCEL_USER_SUSPENSION")
57+
}}
58+
description={
59+
<>
60+
{Language.suspendDialogMessagePrefix} <strong>{userToBeSuspended?.username}</strong>?
61+
</>
62+
}
63+
/>
64+
</>
3665
)
3766
}
3867
}

site/src/pages/UsersPage/UsersPageView.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from "react"
22
import { UserResponse } from "../../api/types"
3+
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
34
import { Header } from "../../components/Header/Header"
45
import { Margins } from "../../components/Margins/Margins"
56
import { Stack } from "../../components/Stack/Stack"
@@ -13,14 +14,21 @@ export const Language = {
1314
export interface UsersPageViewProps {
1415
users: UserResponse[]
1516
openUserCreationDialog: () => void
17+
onSuspendUser: (user: UserResponse) => void
18+
error?: unknown
1619
}
1720

18-
export const UsersPageView: React.FC<UsersPageViewProps> = ({ users, openUserCreationDialog }) => {
21+
export const UsersPageView: React.FC<UsersPageViewProps> = ({
22+
users,
23+
openUserCreationDialog,
24+
onSuspendUser,
25+
error,
26+
}) => {
1927
return (
2028
<Stack spacing={4}>
2129
<Header title={Language.pageTitle} action={{ text: Language.newUserButton, onClick: openUserCreationDialog }} />
2230
<Margins>
23-
<UsersTable users={users} />
31+
{error ? <ErrorSummary error={error} /> : <UsersTable users={users} onSuspendUser={onSuspendUser} />}
2432
</Margins>
2533
</Stack>
2634
)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy