Skip to content

Commit

Permalink
feat(website): create groupManagementApi and add custom hooks and use…
Browse files Browse the repository at this point in the history
… on GroupPage and Manager, adapt e2e

* adapt endpoint to be more restful
  • Loading branch information
TobiasKampmann committed Jan 8, 2024
1 parent 9ab5bd9 commit 3d81c1a
Show file tree
Hide file tree
Showing 29 changed files with 640 additions and 246 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ class ExceptionHandler : ResponseEntityExceptionHandler() {
)
}

@ExceptionHandler(ConflictException::class)
@ResponseStatus(HttpStatus.CONFLICT)
fun handleConflictException(e: Exception): ResponseEntity<ProblemDetail> {
log.warn(e) { "Caught conflict exception: ${e.message}" }

return responseEntity(
HttpStatus.CONFLICT,
e.message,
)
}

@ExceptionHandler(NotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
fun handleNotFoundException(e: NotFoundException): ResponseEntity<ProblemDetail> {
Expand Down Expand Up @@ -149,5 +160,5 @@ class UnprocessableEntityException(message: String) : RuntimeException(message)
class NotFoundException(message: String) : RuntimeException(message)
class ProcessingValidationException(message: String) : RuntimeException(message)
class DuplicateKeyException(message: String) : RuntimeException(message)

class ConflictException(message: String) : RuntimeException(message)
class DummyUnauthorizedExceptionToMakeItAppearInSwaggerUi : RuntimeException()
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
Expand Down Expand Up @@ -60,35 +61,31 @@ class GroupManagementController(

@Operation(description = "Add user to a group.")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PostMapping("/groups/{groupName}/users", produces = [MediaType.APPLICATION_JSON_VALUE])
@PutMapping("/groups/{groupName}/users/{usernameToAdd}", produces = [MediaType.APPLICATION_JSON_VALUE])
fun addUserToGroup(
@UsernameFromJwt groupMember: String,
@Parameter(
description = "The group name the user should be added to.",
) @PathVariable groupName: String,
@Parameter(
description = "The user name that should be added to the group.",
) @RequestBody usernameToAdd: Username,
) = groupManagementDatabaseService.addUserToGroup(groupMember, groupName, usernameToAdd.username)
) @PathVariable usernameToAdd: String,
) = groupManagementDatabaseService.addUserToGroup(groupMember, groupName, usernameToAdd)

@Operation(description = "Remove user from a group.")
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/groups/{groupName}/users", produces = [MediaType.APPLICATION_JSON_VALUE])
@DeleteMapping("/groups/{groupName}/users/{usernameToRemove}", produces = [MediaType.APPLICATION_JSON_VALUE])
fun removeUserFromGroup(
@UsernameFromJwt groupMember: String,
@Parameter(
description = "The group name the user should be removed from.",
) @PathVariable groupName: String,
@Parameter(
description = "The user name that should be removed from the group.",
) @RequestBody usernameToRemove: Username,
) = groupManagementDatabaseService.removeUserFromGroup(groupMember, groupName, usernameToRemove.username)
) @PathVariable usernameToRemove: String,
) = groupManagementDatabaseService.removeUserFromGroup(groupMember, groupName, usernameToRemove)

data class GroupName(
val groupName: String,
)

data class Username(
val username: String,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.loculus.backend.api.Group
import org.loculus.backend.api.GroupDetails
import org.loculus.backend.controller.BadRequestException
import org.loculus.backend.controller.ConflictException
import org.loculus.backend.model.UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand All @@ -36,7 +36,7 @@ class GroupManagementDatabaseService(
}
} catch (e: ExposedSQLException) {
if (e.sqlState == UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE) {
throw BadRequestException(
throw ConflictException(
"Group name already exists. Please choose a different name.",
)
}
Expand Down Expand Up @@ -65,7 +65,7 @@ class GroupManagementDatabaseService(
}
} catch (e: ExposedSQLException) {
if (e.sqlState == UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE) {
throw BadRequestException(
throw ConflictException(
"User $usernameToAdd is already member of the group $groupName.",
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put

class GroupManagementControllerClient(private val mockMvc: MockMvc) {
fun createNewGroup(groupName: String = NEW_GROUP, jwt: String? = jwtForDefaultUser): ResultActions =
Expand All @@ -34,9 +35,7 @@ class GroupManagementControllerClient(private val mockMvc: MockMvc) {
groupName: String = NEW_GROUP,
jwt: String? = jwtForDefaultUser,
): ResultActions = mockMvc.perform(
post("/groups/$groupName/users")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content("""{"username":"$usernameToAdd"}""")
put("/groups/$groupName/users/$usernameToAdd")
.withAuth(jwt),
)

Expand All @@ -45,9 +44,7 @@ class GroupManagementControllerClient(private val mockMvc: MockMvc) {
groupName: String = NEW_GROUP,
jwt: String? = jwtForDefaultUser,
): ResultActions = mockMvc.perform(
delete("/groups/$groupName/users")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content("""{"username":"$userToRemove"}""")
delete("/groups/$groupName/users/$userToRemove")
.withAuth(jwt),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class GroupManagementControllerTest(
fun `GIVEN an existing group WHEN creating a group with same name THEN this is a bad request`() {
client.createNewGroup().andExpect(status().isNoContent)

client.createNewGroup().andExpect(status().isBadRequest)
client.createNewGroup().andExpect(status().isConflict)
}

@Test
Expand Down Expand Up @@ -140,7 +140,7 @@ class GroupManagementControllerTest(
.andExpect(status().isNoContent)

client.addUserToGroup(DEFAULT_USER_NAME)
.andExpect(status().isBadRequest)
.andExpect(status().isConflict)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(
jsonPath("\$.detail").value(
Expand Down
10 changes: 5 additions & 5 deletions website/src/components/ConfirmationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ export const ConfirmationDialog: FC<ConfirmationDialogProps> = ({ dialogText, on

<h3 className='font-bold text-lg'>{dialogText}</h3>

<div className='flex items-center gap-4 mt-4'>
<div className='flex justify-end gap-4 mt-4'>
<form method='dialog'>
<button className='btn' onClick={onConfirmation}>
Confirm
</button>
<button className='btn btn-error'>Cancel</button>
</form>
<form method='dialog'>
<button className='btn btn-error'>Cancel</button>
<button className='btn pathoplexusGreen' onClick={onConfirmation}>
Confirm
</button>
</form>
</div>
</div>
Expand Down
68 changes: 62 additions & 6 deletions website/src/components/DataUploadForm.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { CircularProgress, TextField } from '@mui/material';
import { isErrorFromAlias } from '@zodios/core';
import type { AxiosError } from 'axios';
import { type ChangeEvent, type FormEvent, useState } from 'react';
import { type ChangeEvent, type FormEvent, useMemo, useState } from 'react';

import { withQueryProvider } from './common/withQueryProvider.tsx';
import { getClientLogger } from '../clientLogger.ts';
import { useGroupManagementClient } from '../hooks/useGroupOperations.ts';
import { backendApi } from '../services/backendApi.ts';
import { backendClientHooks } from '../services/serviceHooks.ts';
import type { SubmissionIdMapping } from '../types/backend.ts';
Expand Down Expand Up @@ -36,7 +37,22 @@ const InnerDataUploadForm = ({
const [metadataFile, setMetadataFile] = useState<File | null>(null);
const [sequenceFile, setSequenceFile] = useState<File | null>(null);

const { zodiosHooks } = useGroupManagementClient(clientConfig);
const groupsOfUser = zodiosHooks.useGetGroupsOfUser({
headers: createAuthorizationHeader(accessToken),
});

if (groupsOfUser.error) {
onError(`Failed to query Groups: ${stringifyMaybeAxiosError(groupsOfUser.error)}`);
}

const noGroup = useMemo(
() => groupsOfUser.data === undefined || groupsOfUser.data.length === 0,
[groupsOfUser.data],
);

const { submit, revise, isLoading } = useSubmitFiles(accessToken, organism, clientConfig, onSuccess, onError);
const [selectedGroup, setSelectedGroup] = useState<string | undefined>(undefined);

const handleLoadExampleData = async () => {
const { metadataFileContent, revisedMetadataFileContent, sequenceFileContent } = getExampleData();
Expand All @@ -53,15 +69,23 @@ const InnerDataUploadForm = ({
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();

if (!metadataFile || !sequenceFile) {
onError('Please select both a metadata and sequences file');
if (!metadataFile) {
onError('Please select metadata file');
return;
}
if (!sequenceFile) {
onError('Please select a sequences file');
return;
}

switch (action) {
case 'submit':
// TODO(672): Allow user to specify group name. For now, use default group name from tests.
submit({ metadataFile, sequenceFile, groupName: 'testGroup' });
const groupName = selectedGroup ?? groupsOfUser.data?.[0].groupName;
if (groupName === undefined) {
onError('Please select a group');
return;
}
submit({ metadataFile, sequenceFile, groupName });
break;
case 'revise':
revise({ metadataFile, sequenceFile });
Expand All @@ -71,6 +95,33 @@ const InnerDataUploadForm = ({

return (
<form onSubmit={handleSubmit} className='p-6 space-y-6 max-w-md w-full'>
{action === 'submit' &&
(noGroup ? (
groupsOfUser.isLoading ? (
<p className='text-gray-500'>Loading groups...</p>
) : (
<p className='text-red-500'>No group found. Please join or create a group.</p>
)
) : (
<div className='flex flex-col gap-3 w-fit'>
<span className='text-gray-700'>Submitting for:</span>
<select
id='groupDropdown'
name='groupDropdown'
value={selectedGroup}
onChange={(event) => setSelectedGroup(event.target.value)}
disabled={false}
className='p-2 border rounded-md'
>
{groupsOfUser.data!.map((group) => (
<option key={group.groupName} value={group.groupName}>
{group.groupName}
</option>
))}
</select>
</div>
))}

<TextField
variant='outlined'
margin='dense'
Expand Down Expand Up @@ -98,12 +149,17 @@ const InnerDataUploadForm = ({
shrink: true,
}}
/>

<div className='flex gap-4'>
<button type='button' className='px-4 py-2 btn normal-case ' onClick={handleLoadExampleData}>
Load Example Data
</button>

<button className='px-4 py-2 btn normal-case w-1/5' disabled={isLoading} type='submit'>
<button
className='px-4 py-2 btn normal-case w-1/5'
disabled={isLoading || (action === 'submit' && noGroup)}
type='submit'
>
{isLoading ? <CircularProgress size={20} color='primary' /> : 'Submit'}
</button>
</div>
Expand Down
4 changes: 3 additions & 1 deletion website/src/components/ErrorFeedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import { type FC, type SyntheticEvent, useState } from 'react';

type ErrorFeedbackProps = {
message: string;
onClose?: () => void;
};
export const ErrorFeedback: FC<ErrorFeedbackProps> = ({ message }) => {
export const ErrorFeedback: FC<ErrorFeedbackProps> = ({ message, onClose }) => {
const [open, setOpen] = useState(true);

const handleClose = (_?: SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') {
return;
}
setOpen(false);
onClose?.();
};

const action = (
Expand Down
29 changes: 26 additions & 3 deletions website/src/components/Submission/SubmissionForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const testResponse: SubmissionIdMapping[] = [
describe('SubmitForm', () => {
test('should handle file upload and server response', async () => {
mockRequest.backend.submit(200, testResponse);
mockRequest.backend.getGroupsOfUser();

const { getByLabelText, getByText } = renderSubmissionForm();

Expand All @@ -48,6 +49,7 @@ describe('SubmitForm', () => {

test('should answer with feedback that a file is missing', async () => {
mockRequest.backend.submit(200, testResponse);
mockRequest.backend.getGroupsOfUser();

const { getByLabelText, getByText } = renderSubmissionForm();

Expand All @@ -57,14 +59,34 @@ describe('SubmitForm', () => {
await userEvent.click(submitButton);

await waitFor(() => {
expect(
getByText((text) => text.includes('Please select both a metadata and sequences file')),
).toBeInTheDocument();
expect(getByText((text) => text.includes('Please select a sequences file'))).toBeInTheDocument();
});
});

test('should select a group if there is more than one', async () => {
mockRequest.backend.submit(200, testResponse);
mockRequest.backend.getGroupsOfUser(200, [{ groupName: 'Group1' }, { groupName: 'Group2' }]);

const { getByRole } = renderSubmissionForm();

await waitFor(() => {
expect(getByRole('option', { name: 'Group2' })).toBeInTheDocument();
expect(getByRole('option', { name: 'Group1' })).toBeInTheDocument();
});
});

test('should forbid submitting when there is no group', async () => {
mockRequest.backend.submit(200, testResponse);
mockRequest.backend.getGroupsOfUser(200, []);

const { getByText } = renderSubmissionForm();

await waitFor(() => expect(getByText((text) => text.includes('No group found.'))).toBeInTheDocument());
});

test('should unexpected error with proper error message', async () => {
mockRequest.backend.submit(500, 'a weird, unexpected test error');
mockRequest.backend.getGroupsOfUser();

await submitAndExpectErrorMessageContains('Received unexpected message from backend');
});
Expand All @@ -78,6 +100,7 @@ describe('SubmitForm', () => {
type: 'dummy type',
};
mockRequest.backend.submit(422, problemDetail);
mockRequest.backend.getGroupsOfUser();

const expectedErrorMessage = `The submitted file content was invalid: ${problemDetail.detail}`;
await submitAndExpectErrorMessageContains(expectedErrorMessage);
Expand Down
Loading

0 comments on commit 3d81c1a

Please sign in to comment.