From 6f48e0a23d6834f9d23d2c67225e96f2bf3b44d7 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 20 Sep 2024 17:02:48 +0200 Subject: [PATCH 01/36] fix: make readme file links relative so they are clickable again --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 78e1eeb89..67e4f2102 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ Additional documentation for development is available in each folder's README. T If you would like to develop with a full local loculus instance for development you need to: -1. Deploy a local kubernetes instance: [kubernetes](/kubernetes/README.md) -2. Deploy the backend: [backend](/backend/README.md) -3. Deploy the frontend/website: [website](/website/README.md) +1. Deploy a local kubernetes instance: [kubernetes](./kubernetes/README.md) +2. Deploy the backend: [backend](./backend/README.md) +3. Deploy the frontend/website: [website](./website/README.md) Note that if you are developing the backend or frontend/website in isolation a full local loculus instance is not required. See the individual READMEs for more information. From 6fd03ec80a7daa872eda2efb9eee4167ad3190d5 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 20 Sep 2024 17:03:37 +0200 Subject: [PATCH 02/36] feat: add jq test to script; use 'env' instead of 'bash' directly --- generate_local_test_config.sh | 2 +- get_testuser_token.sh | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/generate_local_test_config.sh b/generate_local_test_config.sh index 027988681..b36b7a7e9 100755 --- a/generate_local_test_config.sh +++ b/generate_local_test_config.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e diff --git a/get_testuser_token.sh b/get_testuser_token.sh index 08ed323ac..b9565c8b6 100755 --- a/get_testuser_token.sh +++ b/get_testuser_token.sh @@ -1,6 +1,12 @@ -#!/usr/bin/bash +#!/usr/bin/env bash set -eu +# Check if jq is installed +if ! command -v jq &> /dev/null; then + echo "Error: jq is not installed. Please install it from https://jqlang.github.io/jq/" + exit 1 +fi + KEYCLOAK_TOKEN_URL="http://localhost:8083/realms/loculus/protocol/openid-connect/token" KEYCLOAK_CLIENT_ID="backend-client" From 18de9293303b0778a4ff0feb082c75483c54e2b9 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 20 Sep 2024 17:04:01 +0200 Subject: [PATCH 03/36] add some documentation to the Kubernetes README --- kubernetes/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/kubernetes/README.md b/kubernetes/README.md index 3163f8aec..d8f1eec73 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -31,10 +31,12 @@ helm install loculus kubernetes/loculus -f my-values.yaml ### Prerequisites Install [k3d](https://k3d.io/v5.6.0/) and [helm](https://helm.sh/). - +We also recommend installing [k9s](https://k9scli.io/) to inspect cluster resources. We deploy to kubernetes via the `../deploy.py` script. It requires you to have `pyyaml` and `requests` installed. +NOTE: On MacOS, make sure that you have configured enough RAM in Docker, we recommend 8GB. + ### Setup for local development #### TLDR From 97c3db078b7f27d28b0c6e77f3b7e673ce9fb3b7 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 20 Sep 2024 17:10:34 +0200 Subject: [PATCH 04/36] WIP - adding the backend stuff --- .../backend/controller/GroupManagementController.kt | 13 +++++++++++++ .../GroupManagementDatabaseService.kt | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt index 3f221704d..67e073217 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt @@ -34,6 +34,19 @@ class GroupManagementController(private val groupManagementDatabaseService: Grou group: NewGroup, ): Group = groupManagementDatabaseService.createNewGroup(group, authenticatedUser) + @Operation(description = "Edit a group. Only users part of the group can edit it.") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PutMapping("/groups/{groupId}", produces = [MediaType.APPLICATION_JSON_VALUE]) + fun editGroup( + @HiddenParam authenticatedUser: AuthenticatedUser, + @Parameter( + description = "The id of the group to edit.", + ) @PathVariable groupId: Int, + @Parameter(description = "Information about the newly created group") + @RequestBody + group: NewGroup, + ) = groupManagementDatabaseService.updateGroup(groupId, group, authenticatedUser) + @Operation(description = "Get details of a group.") @ResponseStatus(HttpStatus.OK) @GetMapping("/groups/{groupId}", produces = [MediaType.APPLICATION_JSON_VALUE]) diff --git a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt index 037a242e7..540ffd3ba 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt @@ -88,6 +88,15 @@ class GroupManagementDatabaseService( ) } + fun updateGroup(groupId: Int, group: NewGroup, authenticatedUser: AuthenticatedUser) { + groupManagementPreconditionValidator.validateThatUserExists(authenticatedUser.username) + + groupManagementPreconditionValidator.validateUserIsAllowedToModifyGroup(groupId, authenticatedUser) + + // TODO do the editing + // Update the groupId group with the info in NewGroup + } + fun getGroupsOfUser(authenticatedUser: AuthenticatedUser): List { val groupsQuery = when (authenticatedUser.isSuperUser) { true -> GroupsTable.selectAll() From 545ea5b7ced351ea4ceb60fc230a030b3f3fcb61 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 20 Sep 2024 17:23:45 +0200 Subject: [PATCH 05/36] move port info higher up in the README --- backend/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/README.md b/backend/README.md index bcbef607c..d606f7206 100644 --- a/backend/README.md +++ b/backend/README.md @@ -31,6 +31,8 @@ All commands mentioned in this section are run from the `backend` directory unle ./start_dev.sh ``` +The service listens, by default, to **port 8079**: . + 3. Clean up the database when done: ```sh @@ -67,8 +69,6 @@ You need to set: We use Flyway, so that the service can provision an empty/existing DB without any manual steps in between. On startup scripts in `src/main/resources/db/migration` are executed in order, i.e. `V1__*.sql` before `V2__*.sql` if they didn't run before, so that the DB is always up-to-date. (For more info on the naming convention, see [this](https://www.red-gate.com/blog/database-devops/flyway-naming-patterns-matter) blog post.) -The service listens, by default, to **port 8079**: . - Note: When using a postgresSQL development platform (e.g. pgAdmin) the hostname is 127.0.0.1 and not localhost - this is defined in the `deploy.py` file. Note that we also use flyway in the ena-submission pod to create an additional schema in the database, ena-submission. This schema is not added here. From 12d5ae4066a4caa1b83da7276dc738f9dc61e5ec Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 20 Sep 2024 17:25:36 +0200 Subject: [PATCH 06/36] Add TODO for test --- .../controller/groupmanagement/GroupManagementControllerTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt index 48119cd23..0afc314cf 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt @@ -41,6 +41,8 @@ class GroupManagementControllerTest(@Autowired private val client: GroupManageme every { keycloakAdapter.getUsersWithName(any()) } returns listOf(UserRepresentation()) } + // TODO add test somewhere here + @Test fun `GIVEN database preparation WHEN getting groups details THEN I get the default group with the default user`() { val defaultGroupId = client.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) From 7656622f588a3727f0aa981b1541696312db1ea1 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 20 Sep 2024 17:30:27 +0200 Subject: [PATCH 07/36] Add more TODOs in relevant files so I don't forget where to change stuff --- website/src/components/User/GroupCreationForm.tsx | 2 ++ website/src/components/User/GroupPage.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/website/src/components/User/GroupCreationForm.tsx b/website/src/components/User/GroupCreationForm.tsx index b03f12392..2ac0fc308 100644 --- a/website/src/components/User/GroupCreationForm.tsx +++ b/website/src/components/User/GroupCreationForm.tsx @@ -15,6 +15,8 @@ interface GroupManagerProps { const chooseCountry = 'Choose a country...'; +// TODO probably reuse part of this UI for the new group edit UI + const InnerGroupCreationForm: FC = ({ clientConfig, accessToken }) => { const [errorMessage, setErrorMessage] = useState(undefined); diff --git a/website/src/components/User/GroupPage.tsx b/website/src/components/User/GroupPage.tsx index 108d21e55..3b0dd496c 100644 --- a/website/src/components/User/GroupPage.tsx +++ b/website/src/components/User/GroupPage.tsx @@ -20,6 +20,8 @@ type GroupPageProps = { userGroups: Group[]; }; +// TODO add edit button somewhere here + const InnerGroupPage: FC = ({ prefetchedGroupDetails, clientConfig, From 7362eacad2d518b78b6ce8fc00b56098246395c7 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 27 Sep 2024 11:34:34 +0100 Subject: [PATCH 08/36] Add code to update the group --- .../controller/GroupManagementController.kt | 8 +- .../GroupManagementDatabaseService.kt | 79 +++++++++---------- 2 files changed, 43 insertions(+), 44 deletions(-) diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt index 67e073217..decda074c 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt @@ -29,12 +29,12 @@ class GroupManagementController(private val groupManagementDatabaseService: Grou @PostMapping("/groups", produces = [MediaType.APPLICATION_JSON_VALUE]) fun createNewGroup( @HiddenParam authenticatedUser: AuthenticatedUser, - @Parameter(description = "Information about the newly created group") + @Parameter(description = "Information about the newly created group.") @RequestBody group: NewGroup, ): Group = groupManagementDatabaseService.createNewGroup(group, authenticatedUser) - @Operation(description = "Edit a group. Only users part of the group can edit it.") + @Operation(description = "Edit a group. Only users part of the group can edit it. The updated group is returned.") @ResponseStatus(HttpStatus.NO_CONTENT) @PutMapping("/groups/{groupId}", produces = [MediaType.APPLICATION_JSON_VALUE]) fun editGroup( @@ -42,10 +42,10 @@ class GroupManagementController(private val groupManagementDatabaseService: Grou @Parameter( description = "The id of the group to edit.", ) @PathVariable groupId: Int, - @Parameter(description = "Information about the newly created group") + @Parameter(description = "Updated group properties.") @RequestBody group: NewGroup, - ) = groupManagementDatabaseService.updateGroup(groupId, group, authenticatedUser) + ): Group = groupManagementDatabaseService.updateGroup(groupId, group, authenticatedUser) @Operation(description = "Get details of a group.") @ResponseStatus(HttpStatus.OK) diff --git a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt index 540ffd3ba..f4c059de4 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt @@ -32,35 +32,14 @@ class GroupManagementDatabaseService( val users = UserGroupEntity.find { UserGroupsTable.groupIdColumn eq groupId } return GroupDetails( - group = Group( - groupId = groupEntity.id.value, - groupName = groupEntity.groupName, - institution = groupEntity.institution, - address = Address( - line1 = groupEntity.addressLine1, - line2 = groupEntity.addressLine2, - postalCode = groupEntity.addressPostalCode, - city = groupEntity.addressCity, - state = groupEntity.addressState, - country = groupEntity.addressCountry, - ), - contactEmail = groupEntity.contactEmail, - ), + group = groupEntity.toGroup(), users = users.map { User(it.userName) }, ) } fun createNewGroup(group: NewGroup, authenticatedUser: AuthenticatedUser): Group { val groupEntity = GroupEntity.new { - groupName = group.groupName - institution = group.institution - addressLine1 = group.address.line1 - addressLine2 = group.address.line2 - addressPostalCode = group.address.postalCode - addressState = group.address.state - addressCity = group.address.city - addressCountry = group.address.country - contactEmail = group.contactEmail + this.updateWith(group) } val groupId = groupEntity.id.value @@ -72,29 +51,21 @@ class GroupManagementDatabaseService( auditLogger.log(authenticatedUser.username, "Created group: ${group.groupName}") - return Group( - groupId = groupEntity.id.value, - groupName = groupEntity.groupName, - institution = groupEntity.institution, - address = Address( - line1 = groupEntity.addressLine1, - line2 = groupEntity.addressLine2, - postalCode = groupEntity.addressPostalCode, - city = groupEntity.addressCity, - state = groupEntity.addressState, - country = groupEntity.addressCountry, - ), - contactEmail = groupEntity.contactEmail, - ) + return groupEntity.toGroup() } - fun updateGroup(groupId: Int, group: NewGroup, authenticatedUser: AuthenticatedUser) { + fun updateGroup(groupId: Int, group: NewGroup, authenticatedUser: AuthenticatedUser): Group { groupManagementPreconditionValidator.validateThatUserExists(authenticatedUser.username) groupManagementPreconditionValidator.validateUserIsAllowedToModifyGroup(groupId, authenticatedUser) - // TODO do the editing - // Update the groupId group with the info in NewGroup + val groupEntity = GroupEntity.findById(groupId) ?: throw NotFoundException("Group $groupId does not exist.") + + groupEntity.updateWith(group) + + auditLogger.log(authenticatedUser.username, "Updated group: ${group.groupName}") + + return groupEntity.toGroup() } fun getGroupsOfUser(authenticatedUser: AuthenticatedUser): List { @@ -181,4 +152,32 @@ class GroupManagementDatabaseService( contactEmail = it.contactEmail, ) } + + private fun GroupEntity.updateWith(group: NewGroup) { + groupName = group.groupName + institution = group.institution + addressLine1 = group.address.line1 + addressLine2 = group.address.line2 + addressPostalCode = group.address.postalCode + addressState = group.address.state + addressCity = group.address.city + addressCountry = group.address.country + contactEmail = group.contactEmail + } + + private fun GroupEntity.toGroup(): Group = + Group( + groupId = this.id.value, + groupName = this.groupName, + institution = this.institution, + address = Address( + line1 = this.addressLine1, + line2 = this.addressLine2, + postalCode = this.addressPostalCode, + city = this.addressCity, + state = this.addressState, + country = this.addressCountry, + ), + contactEmail = this.contactEmail, + ) } From 0dc21ca6427d431dcf12e68b96f544292b14bc0d Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 27 Sep 2024 12:17:29 +0100 Subject: [PATCH 09/36] linter fixes --- .../GroupManagementDatabaseService.kt | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt index f4c059de4..a40177e6c 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/groupmanagement/GroupManagementDatabaseService.kt @@ -165,19 +165,18 @@ class GroupManagementDatabaseService( contactEmail = group.contactEmail } - private fun GroupEntity.toGroup(): Group = - Group( - groupId = this.id.value, - groupName = this.groupName, - institution = this.institution, - address = Address( - line1 = this.addressLine1, - line2 = this.addressLine2, - postalCode = this.addressPostalCode, - city = this.addressCity, - state = this.addressState, - country = this.addressCountry, - ), - contactEmail = this.contactEmail, - ) + private fun GroupEntity.toGroup(): Group = Group( + groupId = this.id.value, + groupName = this.groupName, + institution = this.institution, + address = Address( + line1 = this.addressLine1, + line2 = this.addressLine2, + postalCode = this.addressPostalCode, + city = this.addressCity, + state = this.addressState, + country = this.addressCountry, + ), + contactEmail = this.contactEmail, + ) } From 6872d7f81d733f216d47021a65316dad36971ed9 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 27 Sep 2024 12:33:27 +0100 Subject: [PATCH 10/36] Add docker check to gradle to prevent cryptic test failures due to docker not started --- backend/build.gradle | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/build.gradle b/backend/build.gradle index 816304c0a..220626a5d 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -72,7 +72,25 @@ dependencies { } } +// Check if the docker engine is running and reachable +task checkDocker { + doLast { + def process = "docker info".execute() + def output = new StringWriter() + def error = new StringWriter() + process.consumeProcessOutput(output, error) + process.waitFor() + + if (process.exitValue() != 0) { + throw new GradleException("Docker is not running: ${error.toString()}") + } + println "Docker is running." + } +} + tasks.named('test') { + // Docker is required to start the testing database + dependsOn checkDocker useJUnitPlatform() testLogging { events TestLogEvent.FAILED From 8c44d79b674eb362ba2febbcc8e2c0209f057198 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 27 Sep 2024 13:05:10 +0100 Subject: [PATCH 11/36] Add test --- .../controller/GroupManagementController.kt | 2 +- .../GroupManagementControllerClient.kt | 7 +++ .../GroupManagementControllerTest.kt | 48 ++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt index decda074c..0bd149518 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/GroupManagementController.kt @@ -35,7 +35,7 @@ class GroupManagementController(private val groupManagementDatabaseService: Grou ): Group = groupManagementDatabaseService.createNewGroup(group, authenticatedUser) @Operation(description = "Edit a group. Only users part of the group can edit it. The updated group is returned.") - @ResponseStatus(HttpStatus.NO_CONTENT) + @ResponseStatus(HttpStatus.OK) @PutMapping("/groups/{groupId}", produces = [MediaType.APPLICATION_JSON_VALUE]) fun editGroup( @HiddenParam authenticatedUser: AuthenticatedUser, diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt index 1794d877c..7d1da2c40 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt @@ -49,6 +49,13 @@ class GroupManagementControllerClient(private val mockMvc: MockMvc, private val .withAuth(jwt), ) + fun updateGroup(groupId: Int, group: NewGroup = NEW_GROUP, jwt: String? = jwtForDefaultUser): ResultActions = mockMvc.perform( + put("/groups/$groupId") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(group)) + .withAuth(jwt), + ) + fun getGroupsOfUser(jwt: String? = jwtForDefaultUser): ResultActions = mockMvc.perform( get("/user/groups").withAuth(jwt), ) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt index 0afc314cf..151f63db2 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerTest.kt @@ -11,6 +11,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.keycloak.representations.idm.UserRepresentation +import org.loculus.backend.api.Address +import org.loculus.backend.api.NewGroup import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_GROUP import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_GROUP_NAME import org.loculus.backend.controller.ALTERNATIVE_DEFAULT_USER_NAME @@ -41,8 +43,6 @@ class GroupManagementControllerTest(@Autowired private val client: GroupManageme every { keycloakAdapter.getUsersWithName(any()) } returns listOf(UserRepresentation()) } - // TODO add test somewhere here - @Test fun `GIVEN database preparation WHEN getting groups details THEN I get the default group with the default user`() { val defaultGroupId = client.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) @@ -106,6 +106,50 @@ class GroupManagementControllerTest(@Autowired private val client: GroupManageme .andExpect(jsonPath("\$.[0].contactEmail").value(NEW_GROUP.contactEmail)) } + @Test + fun `GIVEN a group is created WHEN I edit the group THEN the group information is updated`() { + val groupId = client.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) + .andExpect(status().isOk) + .andGetGroupId() + val newInfo = NewGroup( + groupName = "Updated group name", + institution = "Updated institution", + address = Address( + line1 = "Updated address line 1", + line2 = "Updated address line 2", + postalCode = "Updated post code", + city = "Updated city", + state = "Updated state", + country = "Updated country", + ), + contactEmail = "Updated email", + ) + client.updateGroup(groupId = groupId, group = newInfo, jwt = jwtForDefaultUser) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$.groupName").value(newInfo.groupName)) + .andExpect(jsonPath("\$.institution").value(newInfo.institution)) + .andExpect(jsonPath("\$.address.line1").value(newInfo.address.line1)) + .andExpect(jsonPath("\$.address.line2").value(newInfo.address.line2)) + .andExpect(jsonPath("\$.address.city").value(newInfo.address.city)) + .andExpect(jsonPath("\$.address.state").value(newInfo.address.state)) + .andExpect(jsonPath("\$.address.postalCode").value(newInfo.address.postalCode)) + .andExpect(jsonPath("\$.address.country").value(newInfo.address.country)) + .andExpect(jsonPath("\$.contactEmail").value(newInfo.contactEmail)) + client.getDetailsOfGroup(groupId = groupId, jwt = jwtForDefaultUser) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$.group.groupName").value(newInfo.groupName)) + .andExpect(jsonPath("\$.group.institution").value(newInfo.institution)) + .andExpect(jsonPath("\$.group.address.line1").value(newInfo.address.line1)) + .andExpect(jsonPath("\$.group.address.line2").value(newInfo.address.line2)) + .andExpect(jsonPath("\$.group.address.city").value(newInfo.address.city)) + .andExpect(jsonPath("\$.group.address.state").value(newInfo.address.state)) + .andExpect(jsonPath("\$.group.address.postalCode").value(newInfo.address.postalCode)) + .andExpect(jsonPath("\$.group.address.country").value(newInfo.address.country)) + .andExpect(jsonPath("\$.group.contactEmail").value(newInfo.contactEmail)) + } + @Test fun `WHEN superuser queries groups of user THEN returns all groups`() { client.createNewGroup(group = DEFAULT_GROUP, jwt = jwtForDefaultUser) From e680f920ece5bc066f5b5a63d564b1ee149bda1b Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 27 Sep 2024 13:10:55 +0100 Subject: [PATCH 12/36] Formatting --- .../GroupManagementControllerClient.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt index 7d1da2c40..39a8351d1 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/groupmanagement/GroupManagementControllerClient.kt @@ -49,12 +49,13 @@ class GroupManagementControllerClient(private val mockMvc: MockMvc, private val .withAuth(jwt), ) - fun updateGroup(groupId: Int, group: NewGroup = NEW_GROUP, jwt: String? = jwtForDefaultUser): ResultActions = mockMvc.perform( - put("/groups/$groupId") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .content(objectMapper.writeValueAsString(group)) - .withAuth(jwt), - ) + fun updateGroup(groupId: Int, group: NewGroup = NEW_GROUP, jwt: String? = jwtForDefaultUser): ResultActions = + mockMvc.perform( + put("/groups/$groupId") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(group)) + .withAuth(jwt), + ) fun getGroupsOfUser(jwt: String? = jwtForDefaultUser): ResultActions = mockMvc.perform( get("/user/groups").withAuth(jwt), From 3bdb1751bdc2face5d59cfa59fd22a3778a28cce Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Fri, 27 Sep 2024 16:56:24 +0100 Subject: [PATCH 13/36] Add Edit group button (WIP) --- website/src/components/User/GroupPage.tsx | 42 ++++++++++++++--------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/website/src/components/User/GroupPage.tsx b/website/src/components/User/GroupPage.tsx index 3b0dd496c..112d5c274 100644 --- a/website/src/components/User/GroupPage.tsx +++ b/website/src/components/User/GroupPage.tsx @@ -96,22 +96,32 @@ const InnerGroupPage: FC = ({ {userIsGroupMember && ( - + <> + + + )} ) : ( From 86f2a5bf3ae6682e7ca8a1912eb4b15427795d1c Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Mon, 30 Sep 2024 11:32:58 +0100 Subject: [PATCH 14/36] Add edit page stub and routing --- website/src/components/User/GroupPage.tsx | 3 +- website/src/pages/group/[groupId]/edit.astro | 57 ++++++++++++++++++++ website/src/routes/routes.ts | 1 + 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 website/src/pages/group/[groupId]/edit.astro diff --git a/website/src/components/User/GroupPage.tsx b/website/src/components/User/GroupPage.tsx index 112d5c274..4ba70c149 100644 --- a/website/src/components/User/GroupPage.tsx +++ b/website/src/components/User/GroupPage.tsx @@ -30,6 +30,7 @@ const InnerGroupPage: FC = ({ userGroups, }) => { const groupName = prefetchedGroupDetails.group.groupName; + const groupId = prefetchedGroupDetails.group.groupId; const [newUserName, setNewUserName] = useState(''); const [errorMessage, setErrorMessage] = useState(undefined); @@ -100,7 +101,7 @@ const InnerGroupPage: FC = ({ + + + + + ); +} + +const fieldMapping = { + groupName: { + id: 'group-name', + required: true, + }, + institution: { + id: 'institution-name', + required: true, + }, + contactEmail: { + id: 'email', + required: true, + }, + country: { + id: 'country', + required: true, + }, + line1: { + id: 'address-line-1', + required: true, + }, + line2: { + id: 'address-line-2', + required: false, + }, + city: { + id: 'city', + required: true, + }, + state: { + id: 'state', + required: false, + }, + postalCode: { + id: 'postal-code', + required: true, + }, +} as const; diff --git a/website/src/components/Group/Inputs.tsx b/website/src/components/Group/Inputs.tsx new file mode 100644 index 000000000..6345e4f0a --- /dev/null +++ b/website/src/components/Group/Inputs.tsx @@ -0,0 +1,237 @@ +import { type ComponentProps, type FC, type FormEvent, type PropsWithChildren, useState } from 'react'; + +import { listOfCountries } from './listOfCountries.ts'; + +const chooseCountry = 'Choose a country...'; + +const fieldMapping = { + groupName: { + id: 'group-name', + required: true, + }, + institution: { + id: 'institution-name', + required: true, + }, + contactEmail: { + id: 'email', + required: true, + }, + country: { + id: 'country', + required: true, + }, + line1: { + id: 'address-line-1', + required: true, + }, + line2: { + id: 'address-line-2', + required: false, + }, + city: { + id: 'city', + required: true, + }, + state: { + id: 'state', + required: false, + }, + postalCode: { + id: 'postal-code', + required: true, + }, +} as const; + +const groupCreationCssClass = + 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6'; + +type LabelledInputContainerProps = PropsWithChildren<{ + label: string; + htmlFor: string; + className: string; + required?: boolean; +}>; + +const LabelledInputContainer: FC = ({ children, label, htmlFor, className, required }) => ( +
+ +
{children}
+
+); + +type TextInputProps = { + className: string; + label: string; + name: string; + fieldMappingKey: keyof typeof fieldMapping; + type: ComponentProps<'input'>['type']; + defaultValue?: string; +}; + +const TextInput: FC = ({ className, label, name, fieldMappingKey, type, defaultValue }) => ( + + + +); + +type GroupNameInputProps = { + defaultValue?: string; +}; + +export const GroupNameInput: FC = ({ defaultValue }) => ( + +); + +type InstitutionNameInputProps = { + defaultValue?: string; +}; + +export const InstitutionNameInput: FC = ({ defaultValue }) => ( + +); + +type EmailContactInputProps = { + defaultValue?: string; +}; + +export const EmailContactInput: FC = ({ defaultValue }) => ( + +); + +type CountryInputProps = { + defaultValue?: string; +}; + +export const CountryInput: FC = ({ defaultValue }) => ( + + + +); + +type AddressLineOneInputProps = { + defaultValue?: string; +}; + +export const AddressLineOneInput: FC = ({ defaultValue }) => ( + +); + +type AddressLineTwoInputProps = { + defaultValue?: string; +}; + +export const AddressLineTwoInput: FC = ({ defaultValue }) => ( + +); + +type CityInputProps = { + defaultValue?: string; +}; + +export const CityInput: FC = ({ defaultValue }) => ( + +); + +type StateInputProps = { + defaultValue?: string; +}; + +export const StateInput: FC = ({ defaultValue }) => ( + +); + + +type PostalCodeInputProps = { + defaultValue?: string; +}; + +export const PostalCodeInput: FC = ({ defaultValue }) => ( + +); diff --git a/website/src/components/User/listOfCountries.ts b/website/src/components/Group/listOfCountries.ts similarity index 100% rename from website/src/components/User/listOfCountries.ts rename to website/src/components/Group/listOfCountries.ts diff --git a/website/src/components/User/GroupCreationForm.tsx b/website/src/components/User/GroupCreationForm.tsx index 2ac0fc308..442844f5a 100644 --- a/website/src/components/User/GroupCreationForm.tsx +++ b/website/src/components/User/GroupCreationForm.tsx @@ -1,269 +1,46 @@ -import { type ComponentProps, type FC, type FormEvent, type PropsWithChildren, useState } from 'react'; +import { type FC } from 'react'; -import { listOfCountries } from './listOfCountries.ts'; -import useClientFlag from '../../hooks/isClient.ts'; import { useGroupCreation } from '../../hooks/useGroupOperations.ts'; import { routes } from '../../routes/routes.ts'; import { type ClientConfig } from '../../types/runtimeConfig.ts'; -import { ErrorFeedback } from '../ErrorFeedback.tsx'; import { withQueryProvider } from '../common/withQueryProvider.tsx'; +import { GroupForm, type GroupSubmitError, type GroupSubmitSuccess } from '../Group/GroupForm.tsx'; +import type { NewGroup } from '../../types/backend.ts'; interface GroupManagerProps { clientConfig: ClientConfig; accessToken: string; } -const chooseCountry = 'Choose a country...'; - -// TODO probably reuse part of this UI for the new group edit UI - const InnerGroupCreationForm: FC = ({ clientConfig, accessToken }) => { - const [errorMessage, setErrorMessage] = useState(undefined); - const { createGroup } = useGroupCreation({ clientConfig, accessToken, }); - const handleCreateGroup = async (e: FormEvent) => { - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - - const groupName = formData.get(fieldMapping.groupName.id) as string; - const institution = formData.get(fieldMapping.institution.id) as string; - const contactEmail = formData.get(fieldMapping.contactEmail.id) as string; - const country = formData.get(fieldMapping.country.id) as string; - const line1 = formData.get(fieldMapping.line1.id) as string; - const line2 = formData.get(fieldMapping.line2.id) as string; - const city = formData.get(fieldMapping.city.id) as string; - const state = formData.get(fieldMapping.state.id) as string; - const postalCode = formData.get(fieldMapping.postalCode.id) as string; - - if (country === chooseCountry) { - setErrorMessage('Please choose a country'); - return false; - } - - const result = await createGroup({ - groupName, - institution, - contactEmail, - address: { line1, line2, city, postalCode, state, country }, - }); + const handleCreateGroup = async (group: NewGroup) => { + const result = await createGroup(group); if (result.succeeded) { - window.location.href = routes.groupOverviewPage(result.group.groupId); + return { + succeeded: true, + nextPageHref: routes.groupOverviewPage(result.group.groupId), + } as GroupSubmitSuccess } else { - setErrorMessage(result.errorMessage); + return { + succeeded: false, + errorMessage: result.errorMessage, + } as GroupSubmitError } }; - const isClient = useClientFlag(); - return ( -
-

Create a new submitting group

- - {errorMessage !== undefined && ( - setErrorMessage(undefined)} /> - )} - -
-
-

- The information you enter on this form will be publicly available on your group page. -

- -
- - - - - - - - - -
- -
- -
-
-
-
+ ); }; export const GroupCreationForm = withQueryProvider(InnerGroupCreationForm); - -const fieldMapping = { - groupName: { - id: 'group-name', - required: true, - }, - institution: { - id: 'institution-name', - required: true, - }, - contactEmail: { - id: 'email', - required: true, - }, - country: { - id: 'country', - required: true, - }, - line1: { - id: 'address-line-1', - required: true, - }, - line2: { - id: 'address-line-2', - required: false, - }, - city: { - id: 'city', - required: true, - }, - state: { - id: 'state', - required: false, - }, - postalCode: { - id: 'postal-code', - required: true, - }, -} as const; - -const groupCreationCssClass = - 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6'; - -type LabelledInputContainerProps = PropsWithChildren<{ - label: string; - htmlFor: string; - className: string; - required?: boolean; -}>; - -const LabelledInputContainer: FC = ({ children, label, htmlFor, className, required }) => ( -
- -
{children}
-
-); - -type TextInputProps = { - className: string; - label: string; - name: string; - fieldMappingKey: keyof typeof fieldMapping; - type: ComponentProps<'input'>['type']; -}; - -const TextInput: FC = ({ className, label, name, fieldMappingKey, type }) => ( - - - -); - -const GroupNameInput = () => ( - -); - -const InstitutionNameInput = () => ( - -); - -const EmailContactInput = () => ( - -); - -const CountryInput = () => ( - - - -); - -const AddressLineOneInput = () => ( - -); - -const AddressLineTwoInput = () => ( - -); - -const CityInput = () => ( - -); - -const StateInput = () => ( - -); - -const PostalCodeInput = () => ( - -); diff --git a/website/src/components/User/GroupEditForm.tsx b/website/src/components/User/GroupEditForm.tsx new file mode 100644 index 000000000..033fc55c1 --- /dev/null +++ b/website/src/components/User/GroupEditForm.tsx @@ -0,0 +1,50 @@ +import { type FC } from 'react'; + +import { useGroupCreation, useGroupEdit } from '../../hooks/useGroupOperations.ts'; +import { routes } from '../../routes/routes.ts'; +import { type ClientConfig } from '../../types/runtimeConfig.ts'; +import { withQueryProvider } from '../common/withQueryProvider.tsx'; +import { GroupForm, type GroupSubmitError, type GroupSubmitSuccess } from '../Group/GroupForm.tsx'; +import type { Group, GroupDetails, NewGroup } from '../../types/backend.ts'; + +interface GroupEditFormProps { + prefetchedGroupDetails: GroupDetails; + clientConfig: ClientConfig; + accessToken: string; +} + +const InnerGroupEditForm: FC = ({ prefetchedGroupDetails, clientConfig, accessToken }) => { + const {groupId, ...groupInfo} = prefetchedGroupDetails.group; + + const { editGroup } = useGroupEdit({ + clientConfig, + accessToken, + }); + + const handleEditGroup = async (group: NewGroup) => { + const result = await editGroup(groupId, group); + + if (result.succeeded) { + return { + succeeded: true, + nextPageHref: routes.groupOverviewPage(result.group.groupId), + } as GroupSubmitSuccess + } else { + return { + succeeded: false, + errorMessage: result.errorMessage, + } as GroupSubmitError + } + }; + + return ( + + ); +}; + +export const GroupEditForm = withQueryProvider(InnerGroupEditForm); diff --git a/website/src/components/User/GroupPage.tsx b/website/src/components/User/GroupPage.tsx index 4ba70c149..bf2e756d5 100644 --- a/website/src/components/User/GroupPage.tsx +++ b/website/src/components/User/GroupPage.tsx @@ -20,8 +20,6 @@ type GroupPageProps = { userGroups: Group[]; }; -// TODO add edit button somewhere here - const InnerGroupPage: FC = ({ prefetchedGroupDetails, clientConfig, diff --git a/website/src/hooks/useGroupOperations.ts b/website/src/hooks/useGroupOperations.ts index ecf2dd715..183b724a2 100644 --- a/website/src/hooks/useGroupOperations.ts +++ b/website/src/hooks/useGroupOperations.ts @@ -76,6 +76,25 @@ export const useGroupCreation = ({ }; }; +export const useGroupEdit = ({ + clientConfig, + accessToken, +}: { + clientConfig: ClientConfig; + accessToken: string; +}) => { + const { zodios } = useGroupManagementClient(clientConfig); + + const editGroup = useCallback( + async (groupId: number, group: NewGroup) => callEditGroup(accessToken, zodios)(groupId, group), + [accessToken, zodios], + ); + + return { + editGroup, + }; +}; + export const useGroupManagementClient = (clientConfig: ClientConfig) => { const zodios = useMemo(() => new Zodios(clientConfig.backendUrl, groupManagementApi), [clientConfig]); const zodiosHooks = useMemo(() => new ZodiosHooks('loculus', zodios), [zodios]); @@ -115,6 +134,30 @@ function callCreateGroup(accessToken: string, zodios: ZodiosInstance) { + // TODO + return async (groupId: number, group: NewGroup) => { + try { + const groupResult = await zodios.editGroup(group, { + headers: createAuthorizationHeader(accessToken), + params: { + groupId + } + }); + return { + succeeded: true, + group: groupResult, + } as CreateGroupSuccess; // TODO change type + } catch (error) { + const message = `Failed to create group: ${stringifyMaybeAxiosError(error)}`; + return { + succeeded: false, + errorMessage: message, + } as CreateGroupError; + } + }; +} + function callRemoveFromGroup( accessToken: string, openErrorFeedback: (message: string | undefined) => void, diff --git a/website/src/pages/group/[groupId]/edit.astro b/website/src/pages/group/[groupId]/edit.astro index cc35aa4ab..126090b51 100644 --- a/website/src/pages/group/[groupId]/edit.astro +++ b/website/src/pages/group/[groupId]/edit.astro @@ -1,5 +1,5 @@ --- -import { GroupPage } from '../../../components/User/GroupPage'; +import { GroupEditForm } from '../../../components/User/GroupEditForm'; import ErrorBox from '../../../components/common/ErrorBox.tsx'; import NeedToLogin from '../../../components/common/NeedToLogin.astro'; import { getRuntimeConfig } from '../../../config'; @@ -10,7 +10,6 @@ import { getAccessToken } from '../../../utils/getAccessToken'; const session = Astro.locals.session!; const accessToken = getAccessToken(session)!; -const username = session.user?.username ?? ''; const groupId = parseInt(Astro.params.groupId!, 10); const clientConfig = getRuntimeConfig().public; @@ -22,16 +21,11 @@ if (isNaN(groupId)) { const groupManagementClient = GroupManagementClient.create(); const groupDetailsResult = await groupManagementClient.getGroupDetails(accessToken, groupId); -const userGroupsResponse = await groupManagementClient.getGroupsOfUser(accessToken); -const userGroups = userGroupsResponse.match( - (groups) => groups, - () => [], -); --- groupDetails.group.groupName, + (groupDetails) => 'Edit group', () => 'Group error', )} > @@ -41,12 +35,10 @@ const userGroups = userGroupsResponse.match( ) : ( groupDetailsResult.match( (groupDetails) => ( - ), diff --git a/website/src/services/groupManagementApi.ts b/website/src/services/groupManagementApi.ts index 674a2081c..920a1465a 100644 --- a/website/src/services/groupManagementApi.ts +++ b/website/src/services/groupManagementApi.ts @@ -18,6 +18,21 @@ const createGroupEndpoint = makeEndpoint({ response: group, errors: [notAuthorizedError, conflictError], }); +const editGroupEndpoint = makeEndpoint({ + method: 'put', + path: '/groups/:groupId', + alias: 'editGroup', // TODO what does this do? + parameters: [ + authorizationHeader, + { + name: 'data', + type: 'Body', + schema: newGroup, + }, + ], + response: group, + errors: [notAuthorizedError], +}); const addUserToGroupEndpoint = makeEndpoint({ method: 'put', path: '/groups/:groupId/users/:userToAdd', @@ -60,6 +75,7 @@ const getAllGroupsEndpoint = makeEndpoint({ }); export const groupManagementApi = makeApi([ createGroupEndpoint, + editGroupEndpoint, addUserToGroupEndpoint, removeUserFromGroupEndpoint, getGroupDetailsEndpoint, From 510feb6a4319b49ae6675f03bfbadac31db6a5bb Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Mon, 30 Sep 2024 13:28:05 +0100 Subject: [PATCH 16/36] a bit of cleanup --- website/src/components/Group/GroupForm.tsx | 28 +++++++++++++------ website/src/components/Group/Inputs.tsx | 3 +- .../src/components/User/GroupCreationForm.tsx | 16 ++++------- website/src/components/User/GroupEditForm.tsx | 12 ++++---- website/src/hooks/useGroupOperations.ts | 14 +++------- website/src/pages/group/[groupId]/edit.astro | 8 +----- 6 files changed, 36 insertions(+), 45 deletions(-) diff --git a/website/src/components/Group/GroupForm.tsx b/website/src/components/Group/GroupForm.tsx index 1050207bc..f939155b6 100644 --- a/website/src/components/Group/GroupForm.tsx +++ b/website/src/components/Group/GroupForm.tsx @@ -1,15 +1,25 @@ -import { useState, type FC, type FormEvent } from "react"; -import type { NewGroup } from "../../types/backend"; +import { useState, type FC, type FormEvent } from 'react'; + +import { + AddressLineOneInput, + AddressLineTwoInput, + CityInput, + CountryInput, + EmailContactInput, + GroupNameInput, + InstitutionNameInput, + PostalCodeInput, + StateInput, +} from './Inputs'; +import useClientFlag from '../../hooks/isClient'; +import type { NewGroup } from '../../types/backend'; import { ErrorFeedback } from '../ErrorFeedback.tsx'; -import { AddressLineOneInput, AddressLineTwoInput, CityInput, CountryInput, EmailContactInput, GroupNameInput, InstitutionNameInput, PostalCodeInput, StateInput } from "./Inputs"; -import useClientFlag from "../../hooks/isClient"; interface GroupFormProps { title: string; buttonText: string; defaultGroupData?: NewGroup; onSubmit: (group: NewGroup) => Promise; - } export type GroupSubmitSuccess = { @@ -24,7 +34,7 @@ export type GroupSubmitResult = GroupSubmitSuccess | GroupSubmitError; const chooseCountry = 'Choose a country...'; -export const GroupForm: FC = ({title, buttonText, defaultGroupData, onSubmit}) => { +export const GroupForm: FC = ({ title, buttonText, defaultGroupData, onSubmit }) => { const [errorMessage, setErrorMessage] = useState(undefined); const internalOnSubmit = async (e: FormEvent) => { @@ -80,7 +90,7 @@ export const GroupForm: FC = ({title, buttonText, defaultGroupDa

- + @@ -88,7 +98,7 @@ export const GroupForm: FC = ({title, buttonText, defaultGroupDa - +
@@ -104,7 +114,7 @@ export const GroupForm: FC = ({title, buttonText, defaultGroupDa
); -} +}; const fieldMapping = { groupName: { diff --git a/website/src/components/Group/Inputs.tsx b/website/src/components/Group/Inputs.tsx index 6345e4f0a..ceedc5357 100644 --- a/website/src/components/Group/Inputs.tsx +++ b/website/src/components/Group/Inputs.tsx @@ -1,4 +1,4 @@ -import { type ComponentProps, type FC, type FormEvent, type PropsWithChildren, useState } from 'react'; +import { type ComponentProps, type FC, type PropsWithChildren } from 'react'; import { listOfCountries } from './listOfCountries.ts'; @@ -220,7 +220,6 @@ export const StateInput: FC = ({ defaultValue }) => ( /> ); - type PostalCodeInputProps = { defaultValue?: string; }; diff --git a/website/src/components/User/GroupCreationForm.tsx b/website/src/components/User/GroupCreationForm.tsx index 442844f5a..baba09032 100644 --- a/website/src/components/User/GroupCreationForm.tsx +++ b/website/src/components/User/GroupCreationForm.tsx @@ -2,10 +2,10 @@ import { type FC } from 'react'; import { useGroupCreation } from '../../hooks/useGroupOperations.ts'; import { routes } from '../../routes/routes.ts'; +import type { NewGroup } from '../../types/backend.ts'; import { type ClientConfig } from '../../types/runtimeConfig.ts'; -import { withQueryProvider } from '../common/withQueryProvider.tsx'; import { GroupForm, type GroupSubmitError, type GroupSubmitSuccess } from '../Group/GroupForm.tsx'; -import type { NewGroup } from '../../types/backend.ts'; +import { withQueryProvider } from '../common/withQueryProvider.tsx'; interface GroupManagerProps { clientConfig: ClientConfig; @@ -25,22 +25,16 @@ const InnerGroupCreationForm: FC = ({ clientConfig, accessTok return { succeeded: true, nextPageHref: routes.groupOverviewPage(result.group.groupId), - } as GroupSubmitSuccess + } as GroupSubmitSuccess; } else { return { succeeded: false, errorMessage: result.errorMessage, - } as GroupSubmitError + } as GroupSubmitError; } }; - return ( - - ); + return ; }; export const GroupCreationForm = withQueryProvider(InnerGroupCreationForm); diff --git a/website/src/components/User/GroupEditForm.tsx b/website/src/components/User/GroupEditForm.tsx index 033fc55c1..fbf51c0a1 100644 --- a/website/src/components/User/GroupEditForm.tsx +++ b/website/src/components/User/GroupEditForm.tsx @@ -1,11 +1,11 @@ import { type FC } from 'react'; -import { useGroupCreation, useGroupEdit } from '../../hooks/useGroupOperations.ts'; +import { useGroupEdit } from '../../hooks/useGroupOperations.ts'; import { routes } from '../../routes/routes.ts'; +import type { GroupDetails, NewGroup } from '../../types/backend.ts'; import { type ClientConfig } from '../../types/runtimeConfig.ts'; -import { withQueryProvider } from '../common/withQueryProvider.tsx'; import { GroupForm, type GroupSubmitError, type GroupSubmitSuccess } from '../Group/GroupForm.tsx'; -import type { Group, GroupDetails, NewGroup } from '../../types/backend.ts'; +import { withQueryProvider } from '../common/withQueryProvider.tsx'; interface GroupEditFormProps { prefetchedGroupDetails: GroupDetails; @@ -14,7 +14,7 @@ interface GroupEditFormProps { } const InnerGroupEditForm: FC = ({ prefetchedGroupDetails, clientConfig, accessToken }) => { - const {groupId, ...groupInfo} = prefetchedGroupDetails.group; + const { groupId, ...groupInfo } = prefetchedGroupDetails.group; const { editGroup } = useGroupEdit({ clientConfig, @@ -28,12 +28,12 @@ const InnerGroupEditForm: FC = ({ prefetchedGroupDetails, cl return { succeeded: true, nextPageHref: routes.groupOverviewPage(result.group.groupId), - } as GroupSubmitSuccess + } as GroupSubmitSuccess; } else { return { succeeded: false, errorMessage: result.errorMessage, - } as GroupSubmitError + } as GroupSubmitError; } }; diff --git a/website/src/hooks/useGroupOperations.ts b/website/src/hooks/useGroupOperations.ts index 183b724a2..84166b017 100644 --- a/website/src/hooks/useGroupOperations.ts +++ b/website/src/hooks/useGroupOperations.ts @@ -76,13 +76,7 @@ export const useGroupCreation = ({ }; }; -export const useGroupEdit = ({ - clientConfig, - accessToken, -}: { - clientConfig: ClientConfig; - accessToken: string; -}) => { +export const useGroupEdit = ({ clientConfig, accessToken }: { clientConfig: ClientConfig; accessToken: string }) => { const { zodios } = useGroupManagementClient(clientConfig); const editGroup = useCallback( @@ -141,13 +135,13 @@ function callEditGroup(accessToken: string, zodios: ZodiosInstance 'Edit group', - () => 'Group error', - )} -> + { !accessToken ? ( From a4df845e28e1c62bca37b4c550275e929ca8791b Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Mon, 30 Sep 2024 13:43:37 +0100 Subject: [PATCH 17/36] fixed a todo --- website/src/hooks/useGroupOperations.ts | 15 ++++++++++++--- website/src/services/groupManagementApi.ts | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/website/src/hooks/useGroupOperations.ts b/website/src/hooks/useGroupOperations.ts index 84166b017..0a8676656 100644 --- a/website/src/hooks/useGroupOperations.ts +++ b/website/src/hooks/useGroupOperations.ts @@ -128,8 +128,17 @@ function callCreateGroup(accessToken: string, zodios: ZodiosInstance) { - // TODO return async (groupId: number, group: NewGroup) => { try { const groupResult = await zodios.editGroup(group, { @@ -141,13 +150,13 @@ function callEditGroup(accessToken: string, zodios: ZodiosInstance Date: Mon, 30 Sep 2024 16:15:55 +0100 Subject: [PATCH 18/36] cleanup field mapping stuff --- website/src/components/Group/GroupForm.tsx | 64 +---------- website/src/components/Group/Inputs.tsx | 122 ++++++++++----------- 2 files changed, 60 insertions(+), 126 deletions(-) diff --git a/website/src/components/Group/GroupForm.tsx b/website/src/components/Group/GroupForm.tsx index f939155b6..585d8537e 100644 --- a/website/src/components/Group/GroupForm.tsx +++ b/website/src/components/Group/GroupForm.tsx @@ -5,11 +5,13 @@ import { AddressLineTwoInput, CityInput, CountryInput, + CountryInputNoOptionChosen, EmailContactInput, GroupNameInput, InstitutionNameInput, PostalCodeInput, StateInput, + groupFromFormData, } from './Inputs'; import useClientFlag from '../../hooks/isClient'; import type { NewGroup } from '../../types/backend'; @@ -32,8 +34,6 @@ export type GroupSubmitError = { }; export type GroupSubmitResult = GroupSubmitSuccess | GroupSubmitError; -const chooseCountry = 'Choose a country...'; - export const GroupForm: FC = ({ title, buttonText, defaultGroupData, onSubmit }) => { const [errorMessage, setErrorMessage] = useState(undefined); @@ -42,28 +42,13 @@ export const GroupForm: FC = ({ title, buttonText, defaultGroupD const formData = new FormData(e.currentTarget); - const groupName = formData.get(fieldMapping.groupName.id) as string; - const institution = formData.get(fieldMapping.institution.id) as string; - const contactEmail = formData.get(fieldMapping.contactEmail.id) as string; - const country = formData.get(fieldMapping.country.id) as string; - const line1 = formData.get(fieldMapping.line1.id) as string; - const line2 = formData.get(fieldMapping.line2.id) as string; - const city = formData.get(fieldMapping.city.id) as string; - const state = formData.get(fieldMapping.state.id) as string; - const postalCode = formData.get(fieldMapping.postalCode.id) as string; - - if (country === chooseCountry) { + const group = groupFromFormData(formData); + + if (group.address.country === CountryInputNoOptionChosen) { setErrorMessage('Please choose a country'); return false; } - const group: NewGroup = { - groupName, - institution, - contactEmail, - address: { line1, line2, city, postalCode, state, country }, - }; - const result = await onSubmit(group); if (result.succeeded) { @@ -115,42 +100,3 @@ export const GroupForm: FC = ({ title, buttonText, defaultGroupD ); }; - -const fieldMapping = { - groupName: { - id: 'group-name', - required: true, - }, - institution: { - id: 'institution-name', - required: true, - }, - contactEmail: { - id: 'email', - required: true, - }, - country: { - id: 'country', - required: true, - }, - line1: { - id: 'address-line-1', - required: true, - }, - line2: { - id: 'address-line-2', - required: false, - }, - city: { - id: 'city', - required: true, - }, - state: { - id: 'state', - required: false, - }, - postalCode: { - id: 'postal-code', - required: true, - }, -} as const; diff --git a/website/src/components/Group/Inputs.tsx b/website/src/components/Group/Inputs.tsx index ceedc5357..7d687a847 100644 --- a/website/src/components/Group/Inputs.tsx +++ b/website/src/components/Group/Inputs.tsx @@ -1,46 +1,20 @@ import { type ComponentProps, type FC, type PropsWithChildren } from 'react'; import { listOfCountries } from './listOfCountries.ts'; +import type { NewGroup } from '../../types/backend.ts'; -const chooseCountry = 'Choose a country...'; +export const CountryInputNoOptionChosen = 'Choose a country...'; const fieldMapping = { - groupName: { - id: 'group-name', - required: true, - }, - institution: { - id: 'institution-name', - required: true, - }, - contactEmail: { - id: 'email', - required: true, - }, - country: { - id: 'country', - required: true, - }, - line1: { - id: 'address-line-1', - required: true, - }, - line2: { - id: 'address-line-2', - required: false, - }, - city: { - id: 'city', - required: true, - }, - state: { - id: 'state', - required: false, - }, - postalCode: { - id: 'postal-code', - required: true, - }, + groupName: 'group-name', + institution: 'institution-name', + contactEmail: 'email', + country: 'country', + line1: 'address-line-1', + line2: 'address-line-2', + city: 'city', + state: 'state', + postalCode: 'postal-code', } as const; const groupCreationCssClass = @@ -67,23 +41,18 @@ type TextInputProps = { className: string; label: string; name: string; - fieldMappingKey: keyof typeof fieldMapping; + required: boolean; type: ComponentProps<'input'>['type']; defaultValue?: string; }; -const TextInput: FC = ({ className, label, name, fieldMappingKey, type, defaultValue }) => ( - +const TextInput: FC = ({ className, label, name, required, type, defaultValue }) => ( + = ({ defaultValue }) => ( className='sm:col-span-4' type='text' label='Group name' - name='group-name' - fieldMappingKey='groupName' + name={fieldMapping.groupName} + required defaultValue={defaultValue} /> ); @@ -115,8 +84,8 @@ export const InstitutionNameInput: FC = ({ defaultVal className='sm:col-span-4' type='text' label='Institution' - name='institution-name' - fieldMappingKey='institution' + name={fieldMapping.institution} + required defaultValue={defaultValue} /> ); @@ -130,8 +99,8 @@ export const EmailContactInput: FC = ({ defaultValue }) className='sm:col-span-4' type='email' label='Contact email address' - name='email' - fieldMappingKey='contactEmail' + name={fieldMapping.contactEmail} + required defaultValue={defaultValue} /> ); @@ -143,14 +112,14 @@ type CountryInputProps = { export const CountryInput: FC = ({ defaultValue }) => ( Date: Mon, 30 Sep 2024 16:55:48 +0100 Subject: [PATCH 20/36] address review --- website/src/components/Group/Inputs.tsx | 48 +++++++++++++------------ 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/website/src/components/Group/Inputs.tsx b/website/src/components/Group/Inputs.tsx index 478b6c405..82533c206 100644 --- a/website/src/components/Group/Inputs.tsx +++ b/website/src/components/Group/Inputs.tsx @@ -6,15 +6,15 @@ import type { NewGroup } from '../../types/backend.ts'; export const CountryInputNoOptionChosen = 'Choose a country...'; const fieldMapping = { - groupName: 'group-name', - institution: 'institution-name', - contactEmail: 'email', + groupName: 'groupName', + institution: 'institution', + contactEmail: 'contactEmail', country: 'country', - line1: 'address-line-1', - line2: 'address-line-2', + line1: 'line1', + line2: 'line2', city: 'city', state: 'state', - postalCode: 'postal-code', + postalCode: 'postalCode', } as const; const groupCreationCssClass = @@ -110,7 +110,12 @@ type CountryInputProps = { }; export const CountryInput: FC = ({ defaultValue }) => ( - +