diff --git a/cypress/e2e/users.cy.ts b/cypress/e2e/users.cy.ts index 5778f6a93..7dca7b118 100644 --- a/cypress/e2e/users.cy.ts +++ b/cypress/e2e/users.cy.ts @@ -180,7 +180,7 @@ describe('Users', () => { }); }); - describe('modify authorised routes ', () => { + describe('modify authorised routes', () => { beforeEach(() => { cy.visit('/admin/users'); cy.findAllByRole('button', { name: 'Row Actions' }).first().click(); @@ -223,4 +223,34 @@ describe('Users', () => { ); }); }); + + describe('delete users', () => { + beforeEach(() => { + cy.visit('/admin/users'); + cy.findAllByRole('button', { name: 'Row Actions' }).first().click(); + cy.findByText('Delete').click(); + }); + + afterEach(() => { + cy.clearMocks(); + }); + + it('sends a delete request when an admin deletes a user', () => { + cy.findAllByTestId('delete-user-name').should('have.text', 'user1'); + + cy.startSnoopingBrowserMockedRequest(); + + cy.findByRole('button', { name: 'Continue' }).click(); + + cy.findBrowserMockedRequests({ + method: 'DELETE', + url: '/users/:id', + }).should((deleteRequests) => { + expect(deleteRequests.length).equal(1); + const request = deleteRequests[0]; + + expect(request.url.toString()).to.contain('user1'); + }); + }); + }); }); diff --git a/src/admin/users/deleteUserDialogue.component.test.tsx b/src/admin/users/deleteUserDialogue.component.test.tsx new file mode 100644 index 000000000..30d96191c --- /dev/null +++ b/src/admin/users/deleteUserDialogue.component.test.tsx @@ -0,0 +1,70 @@ +import { RenderResult, screen, waitFor } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; +import UsersJson from '../../mocks/users.json'; +import { renderComponentWithProviders } from '../../testUtils'; +import DeleteUserDialogue, { + DeleteUserDialogueProps, +} from './deleteUserDialogue.component'; + +describe('delete user dialogue', () => { + let props: DeleteUserDialogueProps; + let user: UserEvent; + const onClose = vi.fn(); + + const createView = (): RenderResult => { + return renderComponentWithProviders(); + }; + + beforeEach(() => { + props = { + open: true, + onClose: onClose, + selectedUser: UsersJson[0], + }; + user = userEvent.setup(); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + it('renders correctly', async () => { + createView(); + expect(screen.getByText('Delete User')).toBeInTheDocument(); + expect(screen.getByTestId('delete-user-name')).toHaveTextContent('user1'); + }); + + it('calls onClose when Close button is clicked', async () => { + createView(); + const closeButton = screen.getByRole('button', { name: 'Close' }); + await user.click(closeButton); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('displays warning message when user data does not exist in database', async () => { + props = { + ...props, + selectedUser: { ...UsersJson[0], username: 'test' }, + }; + createView(); + + const continueButton = screen.getByRole('button', { name: 'Continue' }); + await user.click(continueButton); + const helperTexts = await screen.findByText( + `username field must exist in the database. You put: 'test'` + ); + expect(helperTexts).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls handleDeleteUser when continue button is clicked with a valid user name', async () => { + createView(); + const continueButton = screen.getByRole('button', { name: 'Continue' }); + await user.click(continueButton); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/admin/users/deleteUserDialogue.component.tsx b/src/admin/users/deleteUserDialogue.component.tsx new file mode 100644 index 000000000..8c61bab9b --- /dev/null +++ b/src/admin/users/deleteUserDialogue.component.tsx @@ -0,0 +1,64 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormHelperText, +} from '@mui/material'; +import { AxiosError } from 'axios'; +import React from 'react'; +import { useDeleteUser } from '../../api/user'; +import { APIError, User } from '../../app.types'; + +export interface DeleteUserDialogueProps { + open: boolean; + onClose: () => void; + selectedUser: User; +} + +const DeleteUserDialogue = (props: DeleteUserDialogueProps) => { + const { open, onClose, selectedUser } = props; + + const [errorMessage, setErrorMessage] = React.useState( + undefined + ); + + const { mutateAsync: deleteUser } = useDeleteUser(); + + const handleClose = React.useCallback(() => { + onClose(); + setErrorMessage(undefined); + }, [onClose]); + + const handleDeleteUser = React.useCallback(() => { + deleteUser(selectedUser.username) + .then(() => { + handleClose(); + }) + .catch((error: AxiosError) => { + const errorDetail = (error.response?.data as APIError).detail; + setErrorMessage(errorDetail as string); + }); + }, [deleteUser, handleClose, selectedUser]); + + return ( + + Delete User + + Are you sure you want to delete{' '} + {selectedUser?.username} + ? + + + + + {errorMessage !== undefined && ( + {errorMessage} + )} + + + ); +}; + +export default DeleteUserDialogue; diff --git a/src/admin/users/usersTable.component.test.tsx b/src/admin/users/usersTable.component.test.tsx index 4f1ff513c..76e7d8558 100644 --- a/src/admin/users/usersTable.component.test.tsx +++ b/src/admin/users/usersTable.component.test.tsx @@ -96,6 +96,33 @@ describe('UsersTable Snapshot', () => { }); }); + it('opens delete dialog and closes it correctly', async () => { + createView(); + await waitFor(() => { + expect(screen.getByText('user1')).toBeInTheDocument(); + }); + + const addButtons = screen.getAllByRole('button', { name: 'Row Actions' }); + await user.click(addButtons[0]); + + await user.click(screen.getByText('Delete')); + + await waitFor(() => { + expect( + screen.getByRole('dialog', { name: 'Delete User' }) + ).toBeInTheDocument(); + }); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + await user.click(closeButton); + + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: 'Delete User' }) + ).not.toBeInTheDocument(); + }); + }); + it('sets the table filters and clears the table filters', async () => { createView(); diff --git a/src/admin/users/usersTable.component.tsx b/src/admin/users/usersTable.component.tsx index 108d8d725..0761868da 100644 --- a/src/admin/users/usersTable.component.tsx +++ b/src/admin/users/usersTable.component.tsx @@ -1,5 +1,6 @@ import AddIcon from '@mui/icons-material/Add'; import ClearIcon from '@mui/icons-material/Clear'; +import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import PasswordIcon from '@mui/icons-material/Password'; import { @@ -20,6 +21,7 @@ import { MRT_Localization_EN } from 'material-react-table/locales/en'; import React from 'react'; import { useUsers } from '../../api/user'; import { User } from '../../app.types'; +import DeleteUserDialogue from './deleteUserDialogue.component'; import UserDialogue from './userDialogue.component'; export const AUTHORISED_ROUTE_LIST = [ @@ -37,7 +39,7 @@ function UsersTable() { const { data: userData, isLoading: userDataLoading } = useUsers(); const [requestType, setRequestType] = React.useState< - 'patchPassword' | 'patchAuthorisedRoutes' | 'post' + 'patchPassword' | 'patchAuthorisedRoutes' | 'post' | 'delete' | false >('post'); const [selectedUser, setSelectedUser] = React.useState( @@ -201,10 +203,38 @@ function UsersTable() { , ] : []), + { + setRequestType('delete'); + setSelectedUser(row.original); + closeMenu(); + }} + sx={{ m: 0 }} + > + + + + Delete + , ]; }, }); - return ; + return ( + <> + + {selectedUser && ( + { + setRequestType(false); + }} + selectedUser={selectedUser} + /> + )} + + ); } export default UsersTable; diff --git a/src/api/user.test.tsx b/src/api/user.test.tsx index f8377f76b..666497855 100644 --- a/src/api/user.test.tsx +++ b/src/api/user.test.tsx @@ -2,7 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import type { UsersDict } from '../app.types'; import usersJson from '../mocks/users.json'; import { hooksWrapperWithProviders } from '../testUtils'; -import { useAddUser, useEditUser, useUsers } from './user'; +import { useAddUser, useDeleteUser, useEditUser, useUsers } from './user'; describe('useUsers', () => { it('sends request to fetch users and returns successful response', async () => { @@ -68,3 +68,24 @@ describe('useEditUser', () => { 'sends axios request to edit a user and throws an appropriate error on failure' ); }); + +describe('useDeleteUser', () => { + it('delete request to delete user and returns successful response', async () => { + const { result } = renderHook(() => useDeleteUser(), { + wrapper: hooksWrapperWithProviders(), + }); + expect(result.current.isIdle).toBe(true); + + result.current.mutate('user1'); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(result.current.data).toEqual(''); + }); + + it.todo( + 'sends axios request to delete user session and throws an appropriate error on failure' + ); +}); diff --git a/src/api/user.tsx b/src/api/user.tsx index bb99632aa..febc3771d 100644 --- a/src/api/user.tsx +++ b/src/api/user.tsx @@ -59,3 +59,21 @@ export const useEditUser = (): UseMutationResult< }, }); }; + +const deleteUser = async (userId: string): Promise => { + return ogApi.delete(`/users/${userId}`).then((response) => response.data); +}; + +export const useDeleteUser = (): UseMutationResult< + void, + AxiosError, + string +> => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (userId: string) => deleteUser(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['Users'] }); + }, + }); +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 560fd3eec..80d326552 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -419,6 +419,21 @@ export const handlers = [ return HttpResponse.json(body._id, { status: 201 }); }), + http.delete('/users/:id', async ({ params }) => { + const { id } = params; + const validId = usersJson.map((user) => user.username); + if (validId.includes(id as string)) { + return new HttpResponse(null, { status: 204 }); + } else { + return HttpResponse.json( + { + detail: `username field must exist in the database. You put: '${id}'`, + }, + { status: 400 } + ); + } + }), + http.post('/users/filters', async () => { return HttpResponse.json('1', { status: 201 }); }),