From bbc2bf1d4ca0b6de6daeeb67f89997a6062679e3 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Mon, 30 Sep 2024 13:23:55 +0100 Subject: [PATCH] 1st working version --- website/src/components/Group/GroupForm.tsx | 146 ++++++++++ website/src/components/Group/Inputs.tsx | 237 ++++++++++++++++ .../{User => Group}/listOfCountries.ts | 0 .../src/components/User/GroupCreationForm.tsx | 259 ++---------------- website/src/components/User/GroupEditForm.tsx | 50 ++++ website/src/components/User/GroupPage.tsx | 2 - website/src/hooks/useGroupOperations.ts | 43 +++ website/src/pages/group/[groupId]/edit.astro | 14 +- website/src/services/groupManagementApi.ts | 16 ++ 9 files changed, 513 insertions(+), 254 deletions(-) create mode 100644 website/src/components/Group/GroupForm.tsx create mode 100644 website/src/components/Group/Inputs.tsx rename website/src/components/{User => Group}/listOfCountries.ts (100%) create mode 100644 website/src/components/User/GroupEditForm.tsx diff --git a/website/src/components/Group/GroupForm.tsx b/website/src/components/Group/GroupForm.tsx new file mode 100644 index 000000000..1050207bc --- /dev/null +++ b/website/src/components/Group/GroupForm.tsx @@ -0,0 +1,146 @@ +import { useState, type FC, type FormEvent } from "react"; +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 = { + succeeded: true; + nextPageHref: string; +}; +export type GroupSubmitError = { + succeeded: false; + errorMessage: string; +}; +export type GroupSubmitResult = GroupSubmitSuccess | GroupSubmitError; + +const chooseCountry = 'Choose a country...'; + +export const GroupForm: FC = ({title, buttonText, defaultGroupData, onSubmit}) => { + const [errorMessage, setErrorMessage] = useState(undefined); + + const internalOnSubmit = 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 group: NewGroup = { + groupName, + institution, + contactEmail, + address: { line1, line2, city, postalCode, state, country }, + }; + + const result = await onSubmit(group); + + if (result.succeeded) { + window.location.href = result.nextPageHref; + } else { + setErrorMessage(result.errorMessage); + } + }; + + const isClient = useClientFlag(); + + return ( +
+

{title}

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

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

+ +
+ + + + + + + + + +
+ +
+ +
+
+
+
+ ); +} + +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,