diff --git a/CHANGELOG.md b/CHANGELOG.md index e644fddc66f5e9fc311451d867eccb4ed1cd17d4..90de22193fb04520cd56769993de421ee5e0f1cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +2024-05-07 - add the option to add not team-visible addresses to the recipients list 2024-05-07 - add exercise name, rework exercise panel add dialogs 2024-05-07 - add learning objectives page to the instructor view 2024-05-07 - change the tab icon to the INJECT logo diff --git a/backend b/backend index caf6b1c1038b43f45124e4a9220e63d822a8e900..b0d99e75be6486619ae5abddcfc347c79c72f54a 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit caf6b1c1038b43f45124e4a9220e63d822a8e900 +Subproject commit b0d99e75be6486619ae5abddcfc347c79c72f54a diff --git a/codegen/gql/queries/ValidateEmailAddress.graphql b/codegen/gql/queries/ValidateEmailAddress.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2ef838079f31ff131544a20ce352e7c46cdd7edb --- /dev/null +++ b/codegen/gql/queries/ValidateEmailAddress.graphql @@ -0,0 +1,3 @@ +query ValidateEmailAddress($exerciseId: ID!, $address: String!) { + validateEmailAddress(exerciseId: $exerciseId, address: $address) +} diff --git a/frontend/src/email/EmailContactSelector/index.tsx b/frontend/src/email/EmailContactSelector/index.tsx index 6f2d7e92ddaaa4c27c278a73a47241b244b08e22..430e86bb34dc29b3f981fd079cd8f8d050c1a191 100644 --- a/frontend/src/email/EmailContactSelector/index.tsx +++ b/frontend/src/email/EmailContactSelector/index.tsx @@ -2,7 +2,9 @@ import { MenuItem, Tooltip } from '@blueprintjs/core' import type { ItemRenderer } from '@blueprintjs/select' import { MultiSelect } from '@blueprintjs/select' import type { EmailParticipant } from '@inject/graphql/fragments/EmailParticipant.generated' -import type { Dispatch, FC, SetStateAction } from 'react' +import { useValidateEmailAddressLazyQuery } from '@inject/graphql/queries/ValidateEmailAddress.generated' +import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' +import type { Dispatch, FC, MouseEventHandler, SetStateAction } from 'react' import { memo, useCallback, useMemo } from 'react' import type { ExtendedItemRenderer } from '../typing' @@ -12,6 +14,7 @@ interface EmailContactSelectorProps { selectedContacts: string[] setSelectedContacts: Dispatch<SetStateAction<string[]>> inInstructor: boolean + exerciseId: string } const EmailContactSelector: FC<EmailContactSelectorProps> = ({ @@ -20,12 +23,17 @@ const EmailContactSelector: FC<EmailContactSelectorProps> = ({ senderAddress, setSelectedContacts, inInstructor, + exerciseId, }) => { const suggestedItems = useMemo( () => emailContacts.map(x => x?.address || '') || [], [emailContacts] ) + const [validateEmailAddress] = useValidateEmailAddressLazyQuery() + + const { notify } = useNotifyContext() + const itemRenderer: ExtendedItemRenderer<string> = useCallback( (i, { handleClick, handleFocus, modifiers, hasTooltip }) => ( <MenuItem @@ -66,20 +74,55 @@ const EmailContactSelector: FC<EmailContactSelectorProps> = ({ [selectedContacts, senderAddress] ) const onItemSelect = useCallback( - (i: string) => + async (i: string) => { + const { data: validateData } = await validateEmailAddress({ + variables: { + exerciseId, + address: i, + }, + }) + if (!validateData?.validateEmailAddress) { + notify('Invalid email address', { intent: 'danger' }) + return + } + setSelectedContacts(prev => { if (prev.includes(i)) { return prev.filter(x => x !== i) } return [...prev, i] - }), - [setSelectedContacts] + }) + }, + [exerciseId, notify, setSelectedContacts, validateEmailAddress] ) const onRemove = useCallback( (i: string) => setSelectedContacts(prev => prev.filter(x => x !== i)), [setSelectedContacts] ) + const createNewItemFromQuery: (query: string) => string | string[] = + useCallback((query: string) => query, []) + const createNewItemRenderer: ( + query: string, + active: boolean, + handleClick: MouseEventHandler<HTMLElement> + ) => undefined | JSX.Element = useCallback( + ( + query: string, + active: boolean, + handleClick: MouseEventHandler<HTMLElement> + ) => ( + <MenuItem + active={active} + onClick={handleClick} + roleStructure='listoption' + shouldDismissPopover={false} + text={query} + /> + ), + [] + ) + return ( <MultiSelect<string> placeholder='Recipients' @@ -98,6 +141,8 @@ const EmailContactSelector: FC<EmailContactSelectorProps> = ({ popoverContentProps={{ style: { maxHeight: '50vh', overflowY: 'auto', overflowX: 'hidden' }, }} + createNewItemFromQuery={createNewItemFromQuery} + createNewItemRenderer={createNewItemRenderer} /> ) } diff --git a/frontend/src/email/EmailForm/TraineeEmailForm.tsx b/frontend/src/email/EmailForm/TraineeEmailForm.tsx index fddea42ed4125ca80328ad2eb8bf01c8f35a8c31..757f4ccf6d7da01c0ab9f3ca3034cc7bbba10f5e 100644 --- a/frontend/src/email/EmailForm/TraineeEmailForm.tsx +++ b/frontend/src/email/EmailForm/TraineeEmailForm.tsx @@ -110,6 +110,7 @@ const TraineeEmailForm: FC<TraineeEmailFormProps> = ({ })} contacts={traineeList} senderAddress={teamAddress} + exerciseId={exerciseId} /> <Divider style={{ margin: '0.5rem 0' }} /> diff --git a/frontend/src/email/EmailForm/modules/HeaderArea.tsx b/frontend/src/email/EmailForm/modules/HeaderArea.tsx index cf3be622b850d13b53b5f69a1e1c1ef6899a4b6e..369735ba78c2844792fbe33426b86db4991647a2 100644 --- a/frontend/src/email/EmailForm/modules/HeaderArea.tsx +++ b/frontend/src/email/EmailForm/modules/HeaderArea.tsx @@ -54,7 +54,6 @@ type HeaderAreaProps = ( template: EmailTemplate | undefined setTemplate: Dispatch<SetStateAction<EmailTemplate | undefined>> teamId: string - exerciseId: string } | { // trainee @@ -67,11 +66,11 @@ type HeaderAreaProps = ( template?: undefined setTemplate?: undefined teamId?: undefined - exerciseId?: undefined } ) & { senderAddress: string contacts: EmailParticipant[] + exerciseId: string } const HeaderArea: FC<HeaderAreaProps> = ({ @@ -136,6 +135,7 @@ const HeaderArea: FC<HeaderAreaProps> = ({ senderAddress={senderAddress} setSelectedContacts={setSelectedContacts} inInstructor={senderList !== undefined} + exerciseId={exerciseId} /> )} diff --git a/graphql/client/apollo-helpers.ts b/graphql/client/apollo-helpers.ts index eae3a4192ac52598b5d6a348b91cf551329f921d..76fc773516248c0b22ef948ea845824818db08cb 100644 --- a/graphql/client/apollo-helpers.ts +++ b/graphql/client/apollo-helpers.ts @@ -421,7 +421,7 @@ export type PasswordChangeKeySpecifier = ('passwordChanged' | PasswordChangeKeyS export type PasswordChangeFieldPolicy = { passwordChanged?: FieldPolicy<any> | FieldReadFunction<any> }; -export type QueryKeySpecifier = ('actionLog' | 'analyticsActionLogs' | 'analyticsEmailThreads' | 'analyticsMilestones' | 'autoInjects' | 'channel' | 'definition' | 'definitions' | 'emailAddresses' | 'emailContact' | 'emailContacts' | 'emailTemplates' | 'emailThread' | 'emailThreads' | 'exerciseChannels' | 'exerciseConfig' | 'exerciseId' | 'exerciseLoopRunning' | 'exerciseQuestionnaires' | 'exerciseTimeLeft' | 'exerciseTools' | 'exercises' | 'extendedTeamTools' | 'fileInfo' | 'groups' | 'milestones' | 'questionnaireState' | 'returnLocalEmailDraft' | 'tags' | 'team' | 'teamActionLogs' | 'teamChannelLogs' | 'teamEmailParticipant' | 'teamInjectSelections' | 'teamLearningObjectives' | 'teamMilestones' | 'teamQuestionnaires' | 'teamRoles' | 'teamTools' | 'teamUploadedFiles' | 'threadTemplate' | 'threadTemplates' | 'user' | 'users' | 'whoAmI' | QueryKeySpecifier)[]; +export type QueryKeySpecifier = ('actionLog' | 'analyticsActionLogs' | 'analyticsEmailThreads' | 'analyticsMilestones' | 'autoInjects' | 'channel' | 'definition' | 'definitions' | 'emailAddresses' | 'emailContact' | 'emailContacts' | 'emailTemplates' | 'emailThread' | 'emailThreads' | 'exerciseChannels' | 'exerciseConfig' | 'exerciseId' | 'exerciseLoopRunning' | 'exerciseQuestionnaires' | 'exerciseTimeLeft' | 'exerciseTools' | 'exercises' | 'extendedTeamTools' | 'fileInfo' | 'groups' | 'milestones' | 'questionnaireState' | 'returnLocalEmailDraft' | 'tags' | 'team' | 'teamActionLogs' | 'teamChannelLogs' | 'teamEmailParticipant' | 'teamInjectSelections' | 'teamLearningObjectives' | 'teamMilestones' | 'teamQuestionnaires' | 'teamRoles' | 'teamTools' | 'teamUploadedFiles' | 'threadTemplate' | 'threadTemplates' | 'user' | 'users' | 'validateEmailAddress' | 'whoAmI' | QueryKeySpecifier)[]; export type QueryFieldPolicy = { actionLog?: FieldPolicy<any> | FieldReadFunction<any>, analyticsActionLogs?: FieldPolicy<any> | FieldReadFunction<any>, @@ -467,6 +467,7 @@ export type QueryFieldPolicy = { threadTemplates?: FieldPolicy<any> | FieldReadFunction<any>, user?: FieldPolicy<any> | FieldReadFunction<any>, users?: FieldPolicy<any> | FieldReadFunction<any>, + validateEmailAddress?: FieldPolicy<any> | FieldReadFunction<any>, whoAmI?: FieldPolicy<any> | FieldReadFunction<any> }; export type QuestionTypeKeySpecifier = ('control' | 'correct' | 'id' | 'labels' | 'max' | 'questionnaire' | 'text' | QuestionTypeKeySpecifier)[]; diff --git a/graphql/graphql.schema.json b/graphql/graphql.schema.json index d443abb9c4457ff63bd00cf2fcbe8c951e7f3238..ab4a0baa19026d20d89e374f32fa352eeec546e2 100644 --- a/graphql/graphql.schema.json +++ b/graphql/graphql.schema.json @@ -7226,6 +7226,51 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "validateEmailAddress", + "description": "Validates if the email address is valid for the specified exercise", + "args": [ + { + "name": "address", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "exerciseId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "whoAmI", "description": "Retrieve data of the currently logged-in user of the request", diff --git a/graphql/queries/ValidateEmailAddress.generated.ts b/graphql/queries/ValidateEmailAddress.generated.ts new file mode 100644 index 0000000000000000000000000000000000000000..713366565c3772df836b63345241c4e04ebf7a5a --- /dev/null +++ b/graphql/queries/ValidateEmailAddress.generated.ts @@ -0,0 +1,51 @@ +/* eslint-disable */ +//@ts-nocheck +import type * as _Types from '../types'; + +import type { DocumentNode } from 'graphql'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ValidateEmailAddressVariables = _Types.Exact<{ + exerciseId: _Types.Scalars['ID']['input']; + address: _Types.Scalars['String']['input']; +}>; + + +export type ValidateEmailAddress = { validateEmailAddress: boolean | null }; + + +export const ValidateEmailAddressDocument = /*#__PURE__*/ {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateEmailAddress"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"exerciseId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"address"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateEmailAddress"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"exerciseId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"exerciseId"}}},{"kind":"Argument","name":{"kind":"Name","value":"address"},"value":{"kind":"Variable","name":{"kind":"Name","value":"address"}}}]}]}}]} as unknown as DocumentNode; + +/** + * __useValidateEmailAddress__ + * + * To run a query within a React component, call `useValidateEmailAddress` and pass it any options that fit your needs. + * When your component renders, `useValidateEmailAddress` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useValidateEmailAddress({ + * variables: { + * exerciseId: // value for 'exerciseId' + * address: // value for 'address' + * }, + * }); + */ +export function useValidateEmailAddress(baseOptions: Apollo.QueryHookOptions<ValidateEmailAddress, ValidateEmailAddressVariables> & ({ variables: ValidateEmailAddressVariables; skip?: boolean; } | { skip: boolean; }) ) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery<ValidateEmailAddress, ValidateEmailAddressVariables>(ValidateEmailAddressDocument, options); + } +export function useValidateEmailAddressLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ValidateEmailAddress, ValidateEmailAddressVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery<ValidateEmailAddress, ValidateEmailAddressVariables>(ValidateEmailAddressDocument, options); + } +export function useValidateEmailAddressSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions<ValidateEmailAddress, ValidateEmailAddressVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery<ValidateEmailAddress, ValidateEmailAddressVariables>(ValidateEmailAddressDocument, options); + } +export type ValidateEmailAddressHookResult = ReturnType<typeof useValidateEmailAddress>; +export type ValidateEmailAddressLazyQueryHookResult = ReturnType<typeof useValidateEmailAddressLazyQuery>; +export type ValidateEmailAddressSuspenseQueryHookResult = ReturnType<typeof useValidateEmailAddressSuspenseQuery>; +export type ValidateEmailAddressQueryResult = Apollo.QueryResult<ValidateEmailAddress, ValidateEmailAddressVariables>; \ No newline at end of file diff --git a/graphql/types.ts b/graphql/types.ts index b392dd2c6575adb797ff2d6665e824d993c6169f..4f0ab216dbb93e370d324e1281256c89761ddb3a 100644 --- a/graphql/types.ts +++ b/graphql/types.ts @@ -811,6 +811,8 @@ export type Query = { user?: Maybe<UserType>; /** Retrieve all users with filtering options */ users?: Maybe<Array<Maybe<UserType>>>; + /** Validates if the email address is valid for the specified exercise */ + validateEmailAddress?: Maybe<Scalars['Boolean']['output']>; /** Retrieve data of the currently logged-in user of the request */ whoAmI?: Maybe<UserType>; }; @@ -1019,6 +1021,12 @@ export type QueryUsersArgs = { filterUsersInput?: InputMaybe<FilterUsersInput>; }; + +export type QueryValidateEmailAddressArgs = { + address: Scalars['String']['input']; + exerciseId: Scalars['ID']['input']; +}; + export type QuestionType = { control: ControlType; correct: Scalars['Int']['output']; @@ -2179,6 +2187,7 @@ export type QueryResolvers<ContextType = any, ParentType extends ResolversParent threadTemplates?: Resolver<Maybe<Array<Maybe<ResolversTypes['EmailTemplateType']>>>, ParentType, ContextType, RequireFields<QueryThreadTemplatesArgs, 'threadId'>>; user?: Resolver<Maybe<ResolversTypes['UserType']>, ParentType, ContextType, Partial<QueryUserArgs>>; users?: Resolver<Maybe<Array<Maybe<ResolversTypes['UserType']>>>, ParentType, ContextType, Partial<QueryUsersArgs>>; + validateEmailAddress?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType, RequireFields<QueryValidateEmailAddressArgs, 'address' | 'exerciseId'>>; whoAmI?: Resolver<Maybe<ResolversTypes['UserType']>, ParentType, ContextType>; }>;