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
)

site/src/testHelpers/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: strin
3030
const renderResult = wrappedRender(
3131
<MemoryRouter initialEntries={[route]}>
3232
<XServiceProvider>
33-
<Routes>
34-
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
35-
</Routes>
33+
<ThemeProvider theme={dark}>
34+
<Routes>
35+
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
36+
</Routes>
37+
</ThemeProvider>
3638
</XServiceProvider>
3739
</MemoryRouter>,
3840
)

0 commit comments

Comments
 (0)