feat: Add suspend user action (#1275)

This commit is contained in:
Bruno Quaresma
2022-05-04 16:10:38 +00:00
committed by GitHub
parent 34b91fd577
commit f911c8a781
13 changed files with 23635 additions and 31 deletions
+1
View File
@@ -1,3 +1,4 @@
import "@testing-library/jest-dom"
import { server } from "./src/testHelpers/server"
// Establish API mocking before all tests through MSW.
+23318
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -58,6 +58,7 @@
"@storybook/addon-essentials": "6.4.22",
"@storybook/addon-links": "6.4.22",
"@storybook/react": "6.4.22",
"@testing-library/jest-dom": "5.16.4",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.1.1",
"@types/express": "4.17.13",
+6 -1
View File
@@ -76,7 +76,7 @@ export const getApiKey = async (): Promise<Types.APIKeyResponse> => {
}
export const getUsers = async (): Promise<TypesGen.User[]> => {
const response = await axios.get<TypesGen.User[]>("/api/v2/users?offset=0&limit=1000")
const response = await axios.get<TypesGen.User[]>("/api/v2/users?status=active")
return response.data
}
@@ -135,3 +135,8 @@ export const updateProfile = async (userId: string, data: Types.UpdateProfileReq
const response = await axios.put(`/api/v2/users/${userId}/profile`, data)
return response.data
}
export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen.User> => {
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/suspend`)
return response.data
}
@@ -1,4 +1,11 @@
import { displaySuccess, isNotificationTextPrefixed, MsgType, NotificationMsg } from "./utils"
import {
displayError,
displaySuccess,
isNotificationTextPrefixed,
MsgType,
NotificationMsg,
SnackbarEventType,
} from "./utils"
describe("Snackbar", () => {
describe("isNotificationTextPrefixed", () => {
@@ -76,4 +83,18 @@ describe("Snackbar", () => {
expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual(expected)
})
})
describe("displayError", () => {
it("shows the title and the message", (done) => {
const message = "Some error happened"
window.addEventListener(SnackbarEventType, (event) => {
const notificationEvent = event as CustomEvent<NotificationMsg>
expect(notificationEvent.detail.msg).toEqual(message)
done()
})
displayError(message)
})
})
})
@@ -60,3 +60,7 @@ export const displayMsg = (msg: string, additionalMsg?: string): void => {
export const displaySuccess = (msg: string, additionalMsg?: string): void => {
dispatchNotificationEvent(MsgType.Success, msg, additionalMsg ? [additionalMsg] : undefined)
}
export const displayError = (msg: string, additionalMsg?: string): void => {
dispatchNotificationEvent(MsgType.Error, msg, additionalMsg ? [additionalMsg] : undefined)
}
@@ -5,7 +5,7 @@ import { Column, Table } from "../Table/Table"
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
import { UserCell } from "../UserCell/UserCell"
const Language = {
export const Language = {
pageTitle: "Users",
usersTitle: "All users",
emptyMessage: "No users found",
@@ -27,9 +27,10 @@ const columns: Column<UserResponse>[] = [
export interface UsersTableProps {
users: UserResponse[]
onSuspendUser: (user: UserResponse) => void
}
export const UsersTable: React.FC<UsersTableProps> = ({ users }) => {
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser }) => {
return (
<Table
columns={columns}
@@ -42,9 +43,7 @@ export const UsersTable: React.FC<UsersTableProps> = ({ users }) => {
menuItems={[
{
label: Language.suspendMenuItem,
onClick: () => {
// TO-DO: Add suspend action here
},
onClick: onSuspendUser,
},
]}
/>
+84 -3
View File
@@ -1,7 +1,38 @@
import { screen } from "@testing-library/react"
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import React from "react"
import { render } from "../../testHelpers"
import { UsersPage } from "./UsersPage"
import * as API from "../../api"
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
import { MockUser, MockUser2, render } from "../../testHelpers"
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"
const suspendUser = async (setupActionSpies: () => void) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
const firstUserRow = users[0].closest("tr")
if (!firstUserRow) {
throw new Error("Error on get the first user row")
}
// Click on the "more" button to display the "Suspend" option
const moreButton = within(firstUserRow).getByLabelText("more")
fireEvent.click(moreButton)
const menu = screen.getByRole("menu")
const suspendButton = within(menu).getByText(UsersTableLanguage.suspendMenuItem)
fireEvent.click(suspendButton)
// Check if the confirm message is displayed
const confirmDialog = screen.getByRole("dialog")
expect(confirmDialog).toHaveTextContent(`${UsersPageLanguage.suspendDialogMessagePrefix} ${MockUser.username}?`)
// Setup spies to check the actions after
setupActionSpies()
// Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByText(UsersPageLanguage.suspendDialogAction)
fireEvent.click(confirmButton)
}
describe("Users Page", () => {
it("shows users", async () => {
@@ -9,4 +40,54 @@ describe("Users Page", () => {
const users = await screen.findAllByText(/.*@coder.com/)
expect(users.length).toEqual(2)
})
describe("suspend user", () => {
describe("when it is success", () => {
it("shows a success message and refresh the page", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)
await suspendUser(() => {
jest.spyOn(API, "suspendUser").mockResolvedValueOnce(MockUser)
jest.spyOn(API, "getUsers").mockImplementationOnce(() => Promise.resolve([MockUser, MockUser2]))
})
// Check if the success message is displayed
await screen.findByText(usersXServiceLanguage.suspendUserSuccess)
// Check if the API was called correctly
expect(API.suspendUser).toBeCalledTimes(1)
expect(API.suspendUser).toBeCalledWith(MockUser.id)
// Check if the users list was reload
await waitFor(() => expect(API.getUsers).toBeCalledTimes(1))
})
})
describe("when it fails", () => {
it("shows an error message", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)
await suspendUser(() => {
jest.spyOn(API, "suspendUser").mockRejectedValueOnce({})
})
// Check if the success message is displayed
await screen.findByText(usersXServiceLanguage.suspendUserError)
// Check if the API was called correctly
expect(API.suspendUser).toBeCalledTimes(1)
expect(API.suspendUser).toBeCalledWith(MockUser.id)
})
})
})
})
+41 -12
View File
@@ -1,16 +1,23 @@
import { useActor } from "@xstate/react"
import React, { useContext, useEffect } from "react"
import { useNavigate } from "react-router"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import { XServiceContext } from "../../xServices/StateContext"
import { UsersPageView } from "./UsersPageView"
export const Language = {
suspendDialogTitle: "Suspend user",
suspendDialogAction: "Suspend",
suspendDialogMessagePrefix: "Do you want to suspend the user",
}
export const UsersPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [usersState, usersSend] = useActor(xServices.usersXService)
const { users, getUsersError } = usersState.context
const { users, getUsersError, userIdToSuspend } = usersState.context
const navigate = useNavigate()
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
/**
* Fetch users on component mount
@@ -19,20 +26,42 @@ export const UsersPage: React.FC = () => {
usersSend("GET_USERS")
}, [usersSend])
if (usersState.matches("error")) {
return <ErrorSummary error={getUsersError} />
}
if (!users) {
return <FullScreenLoader />
} else {
return (
<UsersPageView
users={users}
openUserCreationDialog={() => {
navigate("/users/create")
}}
/>
<>
<UsersPageView
users={users}
openUserCreationDialog={() => {
navigate("/users/create")
}}
onSuspendUser={(user) => {
usersSend({ type: "SUSPEND_USER", userId: user.id })
}}
error={getUsersError}
/>
<ConfirmDialog
type="delete"
hideCancel={false}
open={usersState.matches("confirmUserSuspension")}
confirmLoading={usersState.matches("suspendingUser")}
title={Language.suspendDialogTitle}
confirmText={Language.suspendDialogAction}
onConfirm={() => {
usersSend("CONFIRM_USER_SUSPENSION")
}}
onClose={() => {
usersSend("CANCEL_USER_SUSPENSION")
}}
description={
<>
{Language.suspendDialogMessagePrefix} <strong>{userToBeSuspended?.username}</strong>?
</>
}
/>
</>
)
}
}
+10 -2
View File
@@ -1,5 +1,6 @@
import React from "react"
import { UserResponse } from "../../api/types"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { Header } from "../../components/Header/Header"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
@@ -13,14 +14,21 @@ export const Language = {
export interface UsersPageViewProps {
users: UserResponse[]
openUserCreationDialog: () => void
onSuspendUser: (user: UserResponse) => void
error?: unknown
}
export const UsersPageView: React.FC<UsersPageViewProps> = ({ users, openUserCreationDialog }) => {
export const UsersPageView: React.FC<UsersPageViewProps> = ({
users,
openUserCreationDialog,
onSuspendUser,
error,
}) => {
return (
<Stack spacing={4}>
<Header title={Language.pageTitle} action={{ text: Language.newUserButton, onClick: openUserCreationDialog }} />
<Margins>
<UsersTable users={users} />
{error ? <ErrorSummary error={error} /> : <UsersTable users={users} onSuspendUser={onSuspendUser} />}
</Margins>
</Stack>
)
+5 -3
View File
@@ -30,9 +30,11 @@ export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: strin
const renderResult = wrappedRender(
<MemoryRouter initialEntries={[route]}>
<XServiceProvider>
<Routes>
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
</Routes>
<ThemeProvider theme={dark}>
<Routes>
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
</Routes>
</ThemeProvider>
</XServiceProvider>
</MemoryRouter>,
)
+67 -4
View File
@@ -3,20 +3,29 @@ import * as API from "../../api"
import { ApiError, FieldErrors, isApiError, mapApiErrorToFieldErrors } from "../../api/errors"
import * as Types from "../../api/types"
import * as TypesGen from "../../api/typesGenerated"
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
createUserSuccess: "Successfully created user.",
suspendUserSuccess: "Successfully suspended the user.",
suspendUserError: "Error on suspend the user",
}
export interface UsersContext {
users?: TypesGen.User[]
userIdToSuspend?: TypesGen.User["id"]
getUsersError?: Error | unknown
createUserError?: Error | unknown
createUserFormErrors?: FieldErrors
suspendUserError?: Error | unknown
}
export type UsersEvent = { type: "GET_USERS" } | { type: "CREATE"; user: Types.CreateUserRequest }
export type UsersEvent =
| { type: "GET_USERS" }
| { type: "CREATE"; user: Types.CreateUserRequest }
| { type: "SUSPEND_USER"; userId: TypesGen.User["id"] }
| { type: "CONFIRM_USER_SUSPENSION" }
| { type: "CANCEL_USER_SUSPENSION" }
export const usersMachine = createMachine(
{
@@ -31,18 +40,25 @@ export const usersMachine = createMachine(
createUser: {
data: TypesGen.User
}
suspendUser: {
data: TypesGen.User
}
},
},
id: "usersState",
initial: "idle",
context: {
users: [],
},
initial: "idle",
states: {
idle: {
on: {
GET_USERS: "gettingUsers",
CREATE: "creatingUser",
SUSPEND_USER: {
target: "confirmUserSuspension",
actions: ["assignUserIdToSuspend"],
},
},
},
gettingUsers: {
@@ -86,6 +102,28 @@ export const usersMachine = createMachine(
},
tags: "loading",
},
confirmUserSuspension: {
on: {
CONFIRM_USER_SUSPENSION: "suspendingUser",
CANCEL_USER_SUSPENSION: "idle",
},
},
suspendingUser: {
entry: "clearSuspendUserError",
invoke: {
src: "suspendUser",
id: "suspendUser",
onDone: {
// Update users list
target: "gettingUsers",
actions: ["displaySuspendSuccess"],
},
onError: {
target: "idle",
actions: ["assignSuspendUserError", "displaySuspendedErrorMessage"],
},
},
},
error: {
on: {
GET_USERS: "gettingUsers",
@@ -95,8 +133,18 @@ export const usersMachine = createMachine(
},
{
services: {
getUsers: API.getUsers,
// Passing API.getUsers directly does not invoke the function properly
// when it is mocked. This happen in the UsersPage tests inside of the
// "shows a success message and refresh the page" test case.
getUsers: () => API.getUsers(),
createUser: (_, event) => API.createUser(event.user),
suspendUser: (context) => {
if (!context.userIdToSuspend) {
throw new Error("userIdToSuspend is undefined")
}
return API.suspendUser(context.userIdToSuspend)
},
},
guards: {
isFormError: (_, event) => isApiError(event.data),
@@ -108,6 +156,9 @@ export const usersMachine = createMachine(
assignGetUsersError: assign({
getUsersError: (_, event) => event.data,
}),
assignUserIdToSuspend: assign({
userIdToSuspend: (_, event) => event.userId,
}),
clearGetUsersError: assign((context: UsersContext) => ({
...context,
getUsersError: undefined,
@@ -119,13 +170,25 @@ export const usersMachine = createMachine(
// the guard ensures it is ApiError
createUserFormErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data),
}),
assignSuspendUserError: assign({
suspendUserError: (_, event) => event.data,
}),
clearCreateUserError: assign((context: UsersContext) => ({
...context,
createUserError: undefined,
})),
clearSuspendUserError: assign({
suspendUserError: (_) => undefined,
}),
displayCreateUserSuccess: () => {
displaySuccess(Language.createUserSuccess)
},
displaySuspendSuccess: () => {
displaySuccess(Language.suspendUserSuccess)
},
displaySuspendedErrorMessage: () => {
displayError(Language.suspendUserError)
},
},
},
)
+72
View File
@@ -1077,6 +1077,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.9.2":
version "7.17.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72"
integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.12.7", "@babel/template@^7.16.7", "@babel/template@^7.3.3":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155"
@@ -2727,6 +2734,21 @@
lz-string "^1.4.4"
pretty-format "^27.0.2"
"@testing-library/jest-dom@5.16.4":
version "5.16.4"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz#938302d7b8b483963a3ae821f1c0808f872245cd"
integrity sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==
dependencies:
"@babel/runtime" "^7.9.2"
"@types/testing-library__jest-dom" "^5.9.1"
aria-query "^5.0.0"
chalk "^3.0.0"
css "^3.0.0"
css.escape "^1.5.1"
dom-accessibility-api "^0.5.6"
lodash "^4.17.15"
redent "^3.0.0"
"@testing-library/react-hooks@8.0.0":
version "8.0.0"
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz#7d0164bffce4647f506039de0a97f6fcbd20f4bf"
@@ -2972,6 +2994,14 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@*":
version "27.5.0"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.5.0.tgz#e04ed1824ca6b1dd0438997ba60f99a7405d4c7b"
integrity sha512-9RBFx7r4k+msyj/arpfaa0WOOEcaAZNmN+j80KFbFCoSqCJGHTz7YMAMGQW9Xmqm5w6l5c25vbSjMwlikJi5+g==
dependencies:
jest-matcher-utils "^27.0.0"
pretty-format "^27.0.0"
"@types/jest@27.4.1":
version "27.4.1"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.1.tgz#185cbe2926eaaf9662d340cc02e548ce9e11ab6d"
@@ -3182,6 +3212,13 @@
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310"
integrity sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==
"@types/testing-library__jest-dom@^5.9.1":
version "5.14.3"
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz#ee6c7ffe9f8595882ee7bda8af33ae7b8789ef17"
integrity sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==
dependencies:
"@types/jest" "*"
"@types/uglify-js@*":
version "3.13.1"
resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.13.1.tgz#5e889e9e81e94245c75b6450600e1c5ea2878aea"
@@ -5586,6 +5623,20 @@ css-what@^5.1.0:
resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.1.0.tgz#3f7b707aadf633baf62c2ceb8579b545bb40f7fe"
integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==
css.escape@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=
css@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d"
integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==
dependencies:
inherits "^2.0.4"
source-map "^0.6.1"
source-map-resolve "^0.6.0"
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
@@ -5942,6 +5993,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-accessibility-api@^0.5.6:
version "0.5.14"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56"
integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==
dom-accessibility-api@^0.5.9:
version "0.5.11"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.11.tgz#79d5846c4f90eba3e617d9031e921de9324f84ed"
@@ -11694,6 +11750,14 @@ rechoir@^0.7.0:
dependencies:
resolve "^1.9.0"
redent@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
dependencies:
indent-string "^4.0.0"
strip-indent "^3.0.0"
refractor@^3.1.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.5.0.tgz#334586f352dda4beaf354099b48c2d18e0819aec"
@@ -12459,6 +12523,14 @@ source-map-resolve@^0.5.0:
source-map-url "^0.4.0"
urix "^0.1.0"
source-map-resolve@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2"
integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==
dependencies:
atob "^2.1.2"
decode-uri-component "^0.2.0"
source-map-support@0.4.18:
version "0.4.18"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f"