diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c17f764a872006f49f4cbd82a10e56c5980c18..9c8e8ea0917d73554377b667c5dbd748c289f076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +2024-05-16 - add emails forwarding 2024-05-15 - add role and exercise info into team selectors 2024-05-15 - fix draft persistance 2024-05-15 - add a title to the toolbar and improve the section titles diff --git a/frontend/src/analyst/Emails/index.tsx b/frontend/src/analyst/Emails/index.tsx index f24a6b5f7f24a3ddca905a9a74948d3d05d2ce78..88e19b908dfb5eb8f750dd8ebdba4695d7481e19 100644 --- a/frontend/src/analyst/Emails/index.tsx +++ b/frontend/src/analyst/Emails/index.tsx @@ -111,6 +111,8 @@ const Emails: FC<EmailsProps> = ({ exerciseId, tab, threadId }) => { composeButtonTitle='Composing is not allowed in the analyst view' allowReplying={false} replyButtonTitle='Replying is not allowed in the analyst view' + allowForwarding={false} + forwardButtonTitle='Forwarding is not allowed in the analyst view' /> </div> </div> diff --git a/frontend/src/components/ContentArea/index.tsx b/frontend/src/components/ContentArea/index.tsx index ba337eeacd157845e302f193d8ca4a72e61d6ae4..88de541a13fa49b7b29a4aacf4bbb1ba648e4e3e 100644 --- a/frontend/src/components/ContentArea/index.tsx +++ b/frontend/src/components/ContentArea/index.tsx @@ -1,6 +1,6 @@ import { TextArea } from '@blueprintjs/core' import type { Dispatch, FC, SetStateAction } from 'react' -import { memo, useCallback } from 'react' +import { useCallback } from 'react' interface ContentAreaProps { content: string @@ -16,10 +16,9 @@ const ContentArea: FC<ContentAreaProps> = ({ content, setContent }) => { [setContent] ) - // TODO: optimize defaultValue to not be updated on every render return ( <TextArea - defaultValue={content} + value={content} style={{ resize: 'none', overflowY: 'auto', @@ -32,4 +31,4 @@ const ContentArea: FC<ContentAreaProps> = ({ content, setContent }) => { ) } -export default memo(ContentArea) +export default ContentArea diff --git a/frontend/src/components/ErrorMessage/index.tsx b/frontend/src/components/ErrorMessage/index.tsx index 0989b171a04357d38f7777bfb4ff05d17385d490..9db9602454c26cc4087b884e795434180397aecb 100644 --- a/frontend/src/components/ErrorMessage/index.tsx +++ b/frontend/src/components/ErrorMessage/index.tsx @@ -1,11 +1,12 @@ +import { Colors } from '@blueprintjs/core' import { css } from '@emotion/css' import type { FC, PropsWithChildren } from 'react' const div = css` - background: #c20b0b; - color: var(--white-1); - border-radius: var(--md); - padding: var(--md) var(--lg); + background: ${Colors.RED1}; + color: ${Colors.WHITE}; + border-radius: 0.5rem; + padding: 0.5rem 1rem; ` const ErrorMessage: FC<PropsWithChildren> = ({ children }) => ( diff --git a/frontend/src/email/EmailForm/InstructorEmailForm.tsx b/frontend/src/email/EmailForm/InstructorEmailForm.tsx index f46e2cf8e8687d9d09153ce4b09e5a5f52334e10..87b05e1c71d33c1c8fbe97d2670f5e05b7773e2d 100644 --- a/frontend/src/email/EmailForm/InstructorEmailForm.tsx +++ b/frontend/src/email/EmailForm/InstructorEmailForm.tsx @@ -13,7 +13,6 @@ import { memo, useCallback, useMemo } from 'react' import { MainDrawer } from './css' import HeaderArea from './modules/HeaderArea' import type { EmailFormProps } from './typing' -import useFormState from './useFormState' import useThreadSubmission from './useThreadSubmission' const footer = css` @@ -23,22 +22,12 @@ const footer = css` gap: 0.5rem; ` -interface InstructorEmailFormProps extends EmailFormProps { - exerciseId: string -} - -const InstructorEmailForm: FC<InstructorEmailFormProps> = ({ +const InstructorEmailForm: FC<EmailFormProps> = ({ exerciseId, emailThread, teamId, - onFinish, -}) => { - const formState = useFormState({ - inInstructor: true, - teamId, - emailThreadId: emailThread?.id, - }) - const { + onSuccess, + formState: { fileInfo, setFileInfo, activateMilestone, @@ -57,8 +46,8 @@ const InstructorEmailForm: FC<InstructorEmailFormProps> = ({ setSubject, template, setTemplate, - } = formState - + }, +}) => { const [sendEmailMutate, { loading }] = useSendEmail() const { notify } = useNotifyContext() @@ -82,11 +71,11 @@ const InstructorEmailForm: FC<InstructorEmailFormProps> = ({ }, }, onCompleted: () => { - discardDraft() + discardDraft(false) notify('Email sent successfully') + onSuccess() }, }) - onFinish() }, }) @@ -135,14 +124,14 @@ const InstructorEmailForm: FC<InstructorEmailFormProps> = ({ ) const onDiscard = useCallback(() => { - discardDraft() - onFinish() - }, [discardDraft, onFinish]) + discardDraft(true) + onSuccess() + }, [discardDraft, onSuccess]) const onSave = useCallback(() => { storeDraft() - onFinish() - }, [storeDraft, onFinish]) + onSuccess() + }, [storeDraft, onSuccess]) const onSubmit = useCallback(() => { onSend() diff --git a/frontend/src/email/EmailForm/TraineeEmailForm.tsx b/frontend/src/email/EmailForm/TraineeEmailForm.tsx index 46f278719fb9779ca646130d652da71e7c1a2151..1f108c38ec1363e39a664a47b59e7d7e73f585c1 100644 --- a/frontend/src/email/EmailForm/TraineeEmailForm.tsx +++ b/frontend/src/email/EmailForm/TraineeEmailForm.tsx @@ -11,7 +11,6 @@ import { memo, useCallback } from 'react' import { MainDrawer } from './css' import HeaderArea from './modules/HeaderArea' import type { EmailFormProps } from './typing' -import useFormState from './useFormState' import useThreadSubmission from './useThreadSubmission' const footer = css` @@ -21,18 +20,13 @@ const footer = css` gap: 0.5rem; ` -interface TraineeEmailFormProps extends EmailFormProps { - teamAddress: string -} - -const TraineeEmailForm: FC<TraineeEmailFormProps> = ({ +const TraineeEmailForm: FC<EmailFormProps> = ({ exerciseId, emailThread, - teamAddress, teamId, - onFinish, -}) => { - const { + onSuccess, + formState: { + senderAddress, fileInfo, setFileInfo, content, @@ -43,20 +37,18 @@ const TraineeEmailForm: FC<TraineeEmailFormProps> = ({ setSelectedContacts, subject, setSubject, - } = useFormState({ - teamAddress, - inInstructor: false, - teamId, - emailThreadId: emailThread?.id, - }) - + }, +}) => { const [sendEmailMutate, { loading }] = useSendEmail() const { notify } = useNotifyContext() const { onSend } = useThreadSubmission({ ...(emailThread ? { existingThreadId: emailThread.id } - : { participantAddresses: [...selectedContacts, teamAddress], subject }), + : { + participantAddresses: [...selectedContacts, senderAddress], + subject, + }), exerciseId, content, fileId: fileInfo?.id, @@ -65,17 +57,17 @@ const TraineeEmailForm: FC<TraineeEmailFormProps> = ({ variables: { sendEmailInput: { threadId, - senderAddress: teamAddress, + senderAddress, content, fileId: fileInfo?.id, }, }, onCompleted: () => { - discardDraft() + discardDraft(false) notify('Email sent successfully') + onSuccess() }, }) - onFinish() }, }) @@ -89,14 +81,14 @@ const TraineeEmailForm: FC<TraineeEmailFormProps> = ({ const traineeList = (emailContacts?.emailContacts || []).filter(notEmpty) const onDiscard = useCallback(() => { - discardDraft() - onFinish() - }, [discardDraft, onFinish]) + discardDraft(true) + onSuccess() + }, [discardDraft, onSuccess]) const onSave = useCallback(() => { storeDraft() - onFinish() - }, [storeDraft, onFinish]) + onSuccess() + }, [onSuccess, storeDraft]) return ( <div className={MainDrawer}> @@ -112,7 +104,7 @@ const TraineeEmailForm: FC<TraineeEmailFormProps> = ({ setSubject, })} contacts={traineeList} - senderAddress={teamAddress} + senderAddress={senderAddress} 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 369735ba78c2844792fbe33426b86db4991647a2..ad112c68a30b44a96013049e2145548550ae4912 100644 --- a/frontend/src/email/EmailForm/modules/HeaderArea.tsx +++ b/frontend/src/email/EmailForm/modules/HeaderArea.tsx @@ -9,7 +9,7 @@ import type { EmailThread } from '@inject/graphql/fragments/EmailThread.generate import type { FileInfo } from '@inject/graphql/fragments/FileInfo.generated' import notEmpty from '@inject/shared/utils/notEmpty' import type { Dispatch, FC, SetStateAction } from 'react' -import { memo, useEffect, useMemo } from 'react' +import { useEffect, useMemo } from 'react' const gridContainer = css` display: grid; @@ -145,7 +145,7 @@ const HeaderArea: FC<HeaderAreaProps> = ({ ) : ( <InputGroup placeholder='Subject' - value={subject} + value={subject || ''} onChange={event => setSubject(event.currentTarget.value)} /> )} @@ -173,4 +173,4 @@ const HeaderArea: FC<HeaderAreaProps> = ({ ) } -export default memo(HeaderArea) +export default HeaderArea diff --git a/frontend/src/email/EmailForm/typing.ts b/frontend/src/email/EmailForm/typing.ts index 4c12fb9ae9851584151e6e19d79b243bce708502..7a22a3187708e055ee57bd1c4b762ddd9058c07d 100644 --- a/frontend/src/email/EmailForm/typing.ts +++ b/frontend/src/email/EmailForm/typing.ts @@ -1,8 +1,10 @@ import type { EmailThread } from '@inject/graphql/fragments/EmailThread.generated' +import type { FormState } from './useFormState' export type EmailFormProps = { exerciseId: string emailThread?: EmailThread teamId: string - onFinish: () => void + onSuccess: () => void + formState: FormState } diff --git a/frontend/src/email/EmailForm/useFormState.ts b/frontend/src/email/EmailForm/useFormState.ts index bfe3395207dc0e75206edb851b414d7c736e75c9..7ce4808701aac6b28082d8b22aef99f389c9e5ba 100644 --- a/frontend/src/email/EmailForm/useFormState.ts +++ b/frontend/src/email/EmailForm/useFormState.ts @@ -3,23 +3,26 @@ import type { FileInfo } from '@inject/graphql/fragments/FileInfo.generated' import { useWriteEmailDraft } from '@inject/graphql/mutations/clientonly/WriteEmailDraft.generated' import { useGetEmailTemplateLazyQuery } from '@inject/graphql/queries/GetEmailTemplate.generated' import { useGetFileInfoLazyQuery } from '@inject/graphql/queries/GetFileInfo.generated' -import { useReturnLocalEmailDraft } from '@inject/graphql/queries/clientonly/ReturnLocalEmailDraft.generated' +import { useReturnLocalEmailDraftLazyQuery } from '@inject/graphql/queries/clientonly/ReturnLocalEmailDraft.generated' import { useNotifyContext } from '@inject/shared/notification/contexts/NotifyContext' import notEmpty from '@inject/shared/utils/notEmpty' import type { Dispatch, SetStateAction } from 'react' -import { useEffect, useState } from 'react' +import { useCallback, useState } from 'react' + +export interface FormStateInput { + loadDraft: boolean + emailThreadId: string | undefined + senderAddress?: string + selectedContacts?: string[] + subject?: string | undefined + template?: EmailTemplate | undefined + activateMilestone?: string + deactivateMilestone?: string + content?: string + fileInfo?: FileInfo | undefined +} export interface FormState { - content: string - setContent: Dispatch<SetStateAction<string>> - activateMilestone: string - setActivateMilestone: Dispatch<SetStateAction<string>> - deactivateMilestone: string - setDeactivateMilestone: Dispatch<SetStateAction<string>> - fileInfo: FileInfo | undefined - setFileInfo: Dispatch<SetStateAction<FileInfo | undefined>> - storeDraft: () => void - discardDraft: () => void senderAddress: string setSenderAddress: Dispatch<SetStateAction<string>> selectedContacts: string[] @@ -28,77 +31,130 @@ export interface FormState { setSubject: Dispatch<SetStateAction<string | undefined>> template: EmailTemplate | undefined setTemplate: Dispatch<SetStateAction<EmailTemplate | undefined>> + activateMilestone: string + setActivateMilestone: Dispatch<SetStateAction<string>> + deactivateMilestone: string + setDeactivateMilestone: Dispatch<SetStateAction<string>> + content: string + setContent: Dispatch<SetStateAction<string>> + fileInfo: FileInfo | undefined + setFileInfo: Dispatch<SetStateAction<FileInfo | undefined>> + storeDraft: () => void + discardDraft: (notifyOnSuccess: boolean) => void + reload: (input: FormStateInput) => void } const useFormState = ({ - teamAddress, teamId, inInstructor, - emailThreadId, + teamAddress, }: { teamId: string inInstructor: boolean - emailThreadId: string | undefined - teamAddress?: string | undefined + teamAddress?: string }): FormState => { + const [emailThreadId, setEmailThreadId] = useState<string | undefined>() + const [loadDraft, setLoadDraft] = useState<boolean>() + const [senderAddress, setSenderAddress] = useState<string>(teamAddress || '') - const [content, setContent] = useState<string>('') - const [activateMilestone, setActivateMilestone] = useState<string>('') - const [deactivateMilestone, setDeactivateMilestone] = useState<string>('') - const [fileInfo, setFileInfo] = useState<FileInfo>() const [selectedContacts, setSelectedContacts] = useState<string[]>([]) const [subject, setSubject] = useState<string | undefined>() + const [template, setTemplate] = useState<EmailTemplate>() + const [activateMilestone, setActivateMilestone] = useState<string>('') + const [deactivateMilestone, setDeactivateMilestone] = useState<string>('') + + const [content, setContent] = useState<string>('') + const [fileInfo, setFileInfo] = useState<FileInfo | undefined>() const { notify } = useNotifyContext() const [draftMutate] = useWriteEmailDraft() - const { data: draftData } = useReturnLocalEmailDraft({ - variables: { - teamId, - instructor: inInstructor, - emailThreadId: emailThreadId || null, - }, - }) + const [getDraft] = useReturnLocalEmailDraftLazyQuery() const [getFileInfo] = useGetFileInfoLazyQuery() const [getTemplate] = useGetEmailTemplateLazyQuery() - useEffect(() => { - const draft = draftData?.returnLocalEmailDraft - if (!draft) { - return - } + const loadDraftCallback = useCallback( + async (emailThreadId: string | undefined) => { + getDraft({ + variables: { + teamId, + instructor: inInstructor, + emailThreadId: emailThreadId || null, + }, + onCompleted: data => { + const draft = data?.returnLocalEmailDraft + if (!draft) { + return + } - setContent(draft.content || '') - setActivateMilestone(draft.activateMilestone || '') - setDeactivateMilestone(draft.deactivateMilestone || '') + setContent(draft.content || '') + setActivateMilestone(draft.activateMilestone || '') + setDeactivateMilestone(draft.deactivateMilestone || '') - if (draft.fileId) { - getFileInfo({ - variables: { fileInfoId: draft.fileId }, - onCompleted: data => setFileInfo(data.fileInfo || undefined), - }) - } else { - setFileInfo(undefined) - } - if (draft.templateId) { - getTemplate({ - variables: { templateId: draft.templateId }, - onCompleted: data => setTemplate(data.threadTemplate || undefined), + if (draft.fileId) { + getFileInfo({ + variables: { fileInfoId: draft.fileId }, + onCompleted: data => setFileInfo(data.fileInfo || undefined), + }) + } else { + setFileInfo(undefined) + } + if (draft.templateId) { + getTemplate({ + variables: { templateId: draft.templateId }, + onCompleted: data => + setTemplate(data.threadTemplate || undefined), + }) + } else { + setTemplate(undefined) + } + + if (draft.emailThreadId) { + return + } + + setSenderAddress(draft.senderAddress || teamAddress || '') + setSelectedContacts(draft.selectedContacts?.filter(notEmpty) || []) + setSubject(draft.subject || undefined) + }, }) - } else { - setTemplate(undefined) - } + }, + [getDraft, getFileInfo, getTemplate, inInstructor, teamAddress, teamId] + ) - if (draft.emailThreadId) { - return - } + const reload = useCallback( + ({ + loadDraft, + emailThreadId, + activateMilestone, + content, + deactivateMilestone, + fileInfo, + selectedContacts, + senderAddress, + subject, + template, + }: FormStateInput) => { + setLoadDraft(loadDraft) + setEmailThreadId(emailThreadId) - setSenderAddress(draft.senderAddress || teamAddress || '') - setSelectedContacts(draft.selectedContacts?.filter(notEmpty) || []) - setSubject(draft.subject || undefined) - }, [draftData?.returnLocalEmailDraft, getFileInfo, getTemplate, teamAddress]) + setSenderAddress(senderAddress || teamAddress || '') + setSelectedContacts(selectedContacts || []) + setSubject(subject) + setTemplate(template) + setActivateMilestone(activateMilestone || '') + setDeactivateMilestone(deactivateMilestone || '') + setContent(content || '') + setFileInfo(fileInfo) + + if (loadDraft) { + loadDraftCallback(emailThreadId) + } + }, + [loadDraftCallback, teamAddress] + ) const storeDraft = () => { draftMutate({ @@ -128,7 +184,12 @@ const useFormState = ({ }) } - const discardDraft = () => { + /** + * Resets the form state to the initial state. If the form was loaded from + * a draft, the draft will be discarded + * (currently, that means updating its values to the initial values). + */ + const discardDraft = (notifyOnSuccess: boolean) => { setSenderAddress(teamAddress || '') setContent('') setActivateMilestone('') @@ -138,6 +199,12 @@ const useFormState = ({ setSubject(undefined) setTemplate(undefined) + if (!loadDraft) { + notify('Draft discarded') + return + } + + // TODO: remove draft instead of updating it draftMutate({ variables: { emailDraft: { @@ -154,24 +221,20 @@ const useFormState = ({ templateId: undefined, }, }, - }).catch(err => { - notify(`Error: ${err.message}`, { - intent: 'danger', - }) }) + .then(() => { + if (notifyOnSuccess) { + notify('Draft discarded') + } + }) + .catch(err => { + notify(`Error: ${err.message}`, { + intent: 'danger', + }) + }) } return { - content, - setContent, - activateMilestone, - setActivateMilestone, - deactivateMilestone, - setDeactivateMilestone, - fileInfo, - setFileInfo, - storeDraft, - discardDraft, senderAddress, setSenderAddress, selectedContacts, @@ -180,6 +243,17 @@ const useFormState = ({ setSubject, template, setTemplate, + activateMilestone, + setActivateMilestone, + deactivateMilestone, + setDeactivateMilestone, + content, + setContent, + fileInfo, + setFileInfo, + storeDraft, + discardDraft, + reload, } } diff --git a/frontend/src/email/EmailFormOverlay/TraineeEmailFormOverlay.tsx b/frontend/src/email/EmailFormOverlay/TraineeEmailFormOverlay.tsx index e34fce983aa30f795b1cc6d401ecaeb30da8d73e..ea196bb7f5979877fd42555f9e6cf400ea44ade4 100644 --- a/frontend/src/email/EmailFormOverlay/TraineeEmailFormOverlay.tsx +++ b/frontend/src/email/EmailFormOverlay/TraineeEmailFormOverlay.tsx @@ -1,3 +1,6 @@ +import ErrorMessage from '@/components/ErrorMessage' +import { NonIdealState, Spinner } from '@blueprintjs/core' +import { useGetTeamEmailParticipant } from '@inject/graphql/queries/GetTeamEmailParticipant.generated' import type { FC } from 'react' import EmailFormOverlay from '.' @@ -9,12 +12,43 @@ interface TraineeEmailFormOverlayProps { const TraineeEmailFormOverlay: FC<TraineeEmailFormOverlayProps> = ({ teamId, exerciseId, -}) => ( - <EmailFormOverlay - teamId={teamId} - emailForm='trainee' - exerciseId={exerciseId} - /> -) +}) => { + const { data, loading, error } = useGetTeamEmailParticipant({ + variables: { + teamId, + }, + skip: !teamId, + }) + + if (loading) { + return <Spinner /> + } + if (error) { + return ( + <ErrorMessage> + <h1>Error occurred!</h1> + <p>{error.message}</p> + </ErrorMessage> + ) + } + if (!data || !data.teamEmailParticipant) { + return ( + <NonIdealState + icon='low-voltage-pole' + title='No data' + description='Please wait for the data to come in' + /> + ) + } + + return ( + <EmailFormOverlay + teamId={teamId} + emailForm='trainee' + exerciseId={exerciseId} + teamAddress={data.teamEmailParticipant.address} + /> + ) +} export default TraineeEmailFormOverlay diff --git a/frontend/src/email/EmailFormOverlay/index.tsx b/frontend/src/email/EmailFormOverlay/index.tsx index 0ec121f03cc17c8a2abf3642029d542b50d50353..8fb163816edb62232d8852f40be9927064cc97c4 100644 --- a/frontend/src/email/EmailFormOverlay/index.tsx +++ b/frontend/src/email/EmailFormOverlay/index.tsx @@ -1,11 +1,12 @@ import { Button, Card, Overlay2 } from '@blueprintjs/core' import { css } from '@emotion/css' import type { EmailThread } from '@inject/graphql/fragments/EmailThread.generated' -import { useGetTeamEmailParticipant } from '@inject/graphql/queries/GetTeamEmailParticipant.generated' +import type { FileInfo } from '@inject/graphql/fragments/FileInfo.generated' import type { FC } from 'react' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import InstructorEmailForm from '../EmailForm/InstructorEmailForm' import TraineeEmailForm from '../EmailForm/TraineeEmailForm' +import useFormState from '../EmailForm/useFormState' const card = css` height: 50vh; @@ -20,12 +21,22 @@ const card = css` export const OPEN_COMPOSE_EVENT_TYPE = 'openCompose' export const OPEN_REPLY_EVENT_TYPE = 'openReply' -interface EmailFormOverlayProps { +type EmailFormOverlayProps = { teamId: string - emailForm: 'trainee' | 'instructor' exerciseId: string +} & ( + | { emailForm: 'trainee'; teamAddress: string } + | { emailForm: 'instructor'; teamAddress?: undefined } +) + +interface ComposeInitialValues { + content?: string + fileInfo?: FileInfo + subject?: string } +type OpenComposeEventPayload = ComposeInitialValues | undefined + interface OpenReplyEventPayload { emailThread: EmailThread } @@ -33,26 +44,60 @@ interface OpenReplyEventPayload { const EmailFormOverlay: FC<EmailFormOverlayProps> = ({ teamId, emailForm, + teamAddress, exerciseId, }) => { const [open, setOpen] = useState(false) const [emailThread, setEmailThread] = useState<EmailThread | undefined>() - const handleOpenCompose = () => { - setOpen(true) - setEmailThread(undefined) - } + const formState = useFormState({ + teamAddress: emailForm === 'trainee' ? teamAddress : undefined, + inInstructor: emailForm === 'instructor', + teamId, + }) + const { reload } = formState + + const handleOpenCompose = useCallback( + (event: CustomEvent<OpenComposeEventPayload>) => { + setOpen(true) + setEmailThread(undefined) + + if (event.detail) { + const { content, fileInfo, subject } = event.detail + reload({ + emailThreadId: undefined, + loadDraft: false, + content, + fileInfo, + subject, + }) + } else { + reload({ emailThreadId: undefined, loadDraft: true }) + } + }, + [reload] + ) useEffect(() => { - window.addEventListener(OPEN_COMPOSE_EVENT_TYPE, handleOpenCompose) + window.addEventListener( + OPEN_COMPOSE_EVENT_TYPE, + handleOpenCompose as EventListener + ) return () => { - window.removeEventListener(OPEN_COMPOSE_EVENT_TYPE, handleOpenCompose) + window.removeEventListener( + OPEN_COMPOSE_EVENT_TYPE, + handleOpenCompose as EventListener + ) } - }, []) + }, [handleOpenCompose]) - const handleOpenReply = (event: CustomEvent<OpenReplyEventPayload>) => { - setOpen(true) - setEmailThread(event.detail.emailThread) - } + const handleOpenReply = useCallback( + (event: CustomEvent<OpenReplyEventPayload>) => { + setOpen(true) + setEmailThread(event.detail.emailThread) + reload({ emailThreadId: event.detail.emailThread.id, loadDraft: true }) + }, + [reload] + ) useEffect(() => { window.addEventListener( OPEN_REPLY_EVENT_TYPE, @@ -64,22 +109,7 @@ const EmailFormOverlay: FC<EmailFormOverlayProps> = ({ handleOpenReply as EventListener ) } - }, []) - - const { data: teamParticipantData } = useGetTeamEmailParticipant({ - variables: { - teamId, - }, - skip: !teamId, - }) - const [teamAddress, setTeamAddress] = useState<string>( - teamParticipantData?.teamEmailParticipant?.address || '' - ) - useEffect( - () => - setTeamAddress(teamParticipantData?.teamEmailParticipant?.address || ''), - [teamParticipantData?.teamEmailParticipant?.address] - ) + }, [handleOpenReply]) useEffect(() => { setOpen(false) @@ -104,18 +134,19 @@ const EmailFormOverlay: FC<EmailFormOverlayProps> = ({ /> {emailForm === 'trainee' ? ( <TraineeEmailForm - onFinish={() => setOpen(false)} + onSuccess={() => setOpen(false)} teamId={teamId} exerciseId={exerciseId} - teamAddress={teamAddress} emailThread={emailThread} + formState={formState} /> ) : ( <InstructorEmailForm - onFinish={() => setOpen(false)} + onSuccess={() => setOpen(false)} teamId={teamId} exerciseId={exerciseId} emailThread={emailThread} + formState={formState} /> )} </Card> diff --git a/frontend/src/email/TeamEmails/EmailCard.tsx b/frontend/src/email/TeamEmails/EmailCard.tsx index 8fe1ca6eefb3fce4a8b95bfbcadda2684021859d..71d196ab51ca381bbd57e07e379cc28ec0dc6250 100644 --- a/frontend/src/email/TeamEmails/EmailCard.tsx +++ b/frontend/src/email/TeamEmails/EmailCard.tsx @@ -1,12 +1,28 @@ import useFormatTimestamp from '@/analyst/useFormatTimestamp' import { HIGHLIGHTED_COLOR } from '@/analyst/utilities' import FileViewRedirectButton from '@/components/FileViewRedirectButton' -import { Classes, Colors, Icon, Section, SectionCard } from '@blueprintjs/core' +import { + Button, + Classes, + Colors, + Icon, + Section, + SectionCard, +} from '@blueprintjs/core' +import { css } from '@emotion/css' import type { Email } from '@inject/graphql/fragments/Email.generated' import { useWriteReadReceiptEmail } from '@inject/graphql/mutations/clientonly/WriteReadReceiptEmail.generated' import Timestamp from '@inject/shared/components/Timestamp' import type { FC } from 'react' import { useEffect, useMemo } from 'react' +import { OPEN_COMPOSE_EVENT_TYPE } from '../EmailFormOverlay' + +const rightElement = css` + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: center; +` interface EmailCardProps { exerciseId: string @@ -14,6 +30,9 @@ interface EmailCardProps { email: Email inAnalyst?: boolean inInstructor?: boolean + allowForwarding?: boolean + forwardButtonTitle?: string + subject: string } const EmailCard: FC<EmailCardProps> = ({ @@ -22,6 +41,9 @@ const EmailCard: FC<EmailCardProps> = ({ email, inAnalyst, inInstructor, + allowForwarding, + forwardButtonTitle, + subject, }) => { const formatTimestamp = useFormatTimestamp() @@ -60,7 +82,7 @@ const EmailCard: FC<EmailCardProps> = ({ } title={email.sender.address} rightElement={ - <span> + <div className={rightElement}> {inAnalyst ? ( formatTimestamp(email.timestamp) ) : ( @@ -75,7 +97,26 @@ const EmailCard: FC<EmailCardProps> = ({ }} /> )} - </span> + <Button + minimal + icon='nest' + disabled={!allowForwarding} + title={forwardButtonTitle} + onClick={() => + window.dispatchEvent( + new CustomEvent(OPEN_COMPOSE_EVENT_TYPE, { + detail: { + content: email.content.raw, + fileInfo: email.content.fileInfo, + subject, + }, + }) + ) + } + > + Forward + </Button> + </div> } > <SectionCard> diff --git a/frontend/src/email/TeamEmails/InstructorTeamEmails.tsx b/frontend/src/email/TeamEmails/InstructorTeamEmails.tsx index 96ad42a551a52c0dc6e8aedb10135ec5c82c4ecf..ac651ebff9244e388216ec971be51616e62fc0a2 100644 --- a/frontend/src/email/TeamEmails/InstructorTeamEmails.tsx +++ b/frontend/src/email/TeamEmails/InstructorTeamEmails.tsx @@ -51,6 +51,7 @@ const InstructorTeamEmails: FC<InstructorTeamEmailsProps> = ({ replyButtonTitle={ replyDisabled ? "You can't reply to emails between teams" : undefined } + allowForwarding /> ) } diff --git a/frontend/src/email/TeamEmails/TraineeTeamEmails.tsx b/frontend/src/email/TeamEmails/TraineeTeamEmails.tsx index eeebfa072604035eb84bcbdbc3c52fe0592cf47a..d78312d2bebbcbfe1ec2498b493ffe2d27d19f01 100644 --- a/frontend/src/email/TeamEmails/TraineeTeamEmails.tsx +++ b/frontend/src/email/TeamEmails/TraineeTeamEmails.tsx @@ -41,6 +41,7 @@ const TraineeTeamEmails: FC<TraineeTeamEmailsProps> = ({ onClick={onClick} allowComposing allowReplying + allowForwarding /> ) } diff --git a/frontend/src/email/TeamEmails/index.tsx b/frontend/src/email/TeamEmails/index.tsx index 01b5847f9d186d6418ae3e889a2712a0702d08ba..7b43687fe0be42f97ac006f8283e43f769fea275 100644 --- a/frontend/src/email/TeamEmails/index.tsx +++ b/frontend/src/email/TeamEmails/index.tsx @@ -43,6 +43,8 @@ interface TeamEmailsProps { allowReplying: boolean replyButtonTitle?: string inInstructor?: boolean + allowForwarding: boolean + forwardButtonTitle?: string } const TeamEmails: FC<TeamEmailsProps> = ({ @@ -61,9 +63,17 @@ const TeamEmails: FC<TeamEmailsProps> = ({ allowReplying, replyButtonTitle, inInstructor, + allowForwarding, + forwardButtonTitle, }) => { const emails = useMemo( - () => [...(selectedEmailThread?.emails || [])].reverse(), + () => + [ + ...(selectedEmailThread?.emails.map(email => ({ + ...email, + subject: selectedEmailThread.subject, + })) || []), + ].reverse(), [selectedEmailThread] ) @@ -119,6 +129,9 @@ const TeamEmails: FC<TeamEmailsProps> = ({ email={email} inAnalyst={inAnalyst} inInstructor={inInstructor} + allowForwarding={allowForwarding} + forwardButtonTitle={forwardButtonTitle} + subject={email.subject} /> ))} </div> diff --git a/frontend/src/global.css b/frontend/src/global.css index 7f4526b166382b97ab095eb25fbfa0815cf032e9..e158e8e19abe2906abb76b56b4e14ac5d8e545d1 100644 --- a/frontend/src/global.css +++ b/frontend/src/global.css @@ -5,27 +5,12 @@ html, body, #root { html { overflow-x: hidden; - - /* overflow-y: hidden; */ } body.bp5-dark { background-color: #2f343c; } -body { - --green-1: #12ae12; - --white-1: #fffefe; - --grey-1: #aeaeae; - --grey-2: #9e9e93; - --blue-1: #43d4ee; - --sm: 0.25rem; - --md: 0.5rem; - --lg: 1rem; - --xl: 2rem; - --xxl: 4rem; -} - * { box-sizing: border-box; } @@ -64,7 +49,6 @@ h6 { ::-webkit-scrollbar-track { border-radius: 8px; background-color: transparent; - /* border: 1px solid rgba(255, 255, 255, 0.2); */ border-left: 2px none; border-right: 2px none; border-top: 2px none; diff --git a/frontend/src/pages/(navbar)/exercise-panel.tsx b/frontend/src/pages/(navbar)/exercise-panel.tsx index 0176e6417a470bf5d15e903cdafb35986b37df80..26c041e95f237699ae75870f56ca8f9cf9fb6790 100644 --- a/frontend/src/pages/(navbar)/exercise-panel.tsx +++ b/frontend/src/pages/(navbar)/exercise-panel.tsx @@ -14,7 +14,7 @@ const wrapper = css` ` const ExercisePanel = () => { - useSetPageTitle('Admin Panel') + useSetPageTitle('Exercise Panel') return ( <> diff --git a/frontend/src/pages/(navbar)/graphiql.tsx b/frontend/src/pages/(navbar)/graphiql.tsx index f5b96b241e3c62b570a4b0777ee34e2fd80de646..163b1b6479cc48fe7d554d54c8b7b015c86f31e3 100644 --- a/frontend/src/pages/(navbar)/graphiql.tsx +++ b/frontend/src/pages/(navbar)/graphiql.tsx @@ -1,7 +1,12 @@ +import { useSetPageTitle } from '@/utils' import { lazy } from 'react' const GraphiQLPage = lazy(() => import('@/logic/GraphiQL')) -export const GraphiQL = () => <GraphiQLPage /> +export const GraphiQL = () => { + useSetPageTitle('GraphiQL') + + return <GraphiQLPage /> +} export default GraphiQL diff --git a/frontend/src/pages/(navbar)/index.tsx b/frontend/src/pages/(navbar)/index.tsx index 5e5750aa395014ad03be54ede75123e6b9661271..e72d58d9b131e3d204498706578aa0ebb1e4865e 100644 --- a/frontend/src/pages/(navbar)/index.tsx +++ b/frontend/src/pages/(navbar)/index.tsx @@ -2,14 +2,12 @@ import InjectLogo from '@/assets/inject-logo--vertical-black.svg?react' import StaffSelector from '@/logic/StaffSelector' import TeamSelector from '@/logic/TeamSelector' import { useNavigate } from '@/router' -import { useSetPageTitle } from '@/utils' import { Checkbox, Collapse } from '@blueprintjs/core' import { useAuthIdentity } from '@inject/graphql/auth' import Container from '@inject/shared/components/Container' import { useEffect, useState } from 'react' const Index = () => { - useSetPageTitle('Team Selection') const { isActive, isStaff, isSuperuser, isLogged, loading } = useAuthIdentity( !!window.INJECT_NOAUTH ) diff --git a/frontend/src/pages/(navbar)/settings.tsx b/frontend/src/pages/(navbar)/settings.tsx index 0b742234837d06e2f09e57278c4299101e8030e5..d2fd5696a5bc54dbb21382e444f526f98a85df68 100644 --- a/frontend/src/pages/(navbar)/settings.tsx +++ b/frontend/src/pages/(navbar)/settings.tsx @@ -5,6 +5,7 @@ import InstructorTeams from '@/clientsettings/components/InstructorTeams' import Logout from '@/clientsettings/components/Logout' import Notification from '@/clientsettings/components/Notification' import RelativeTime from '@/clientsettings/components/RelativeTime' +import { useSetPageTitle } from '@/utils' import { css } from '@emotion/css' import { useAuthIdentity } from '@inject/graphql/auth' import Container from '@inject/shared/components/Container' @@ -14,6 +15,7 @@ const heading = css` ` const Settings = () => { + useSetPageTitle('Settings') const { isStaff, isSuperuser } = useAuthIdentity(!!window.INJECT_NOAUTH) return ( diff --git a/frontend/src/pages/404.tsx b/frontend/src/pages/404.tsx index c138c3fe7f6aa763482ce5aca8d1ec9ee0681a27..3f11b366fa20589ae6d7f31ef06f44ebf346b4e7 100644 --- a/frontend/src/pages/404.tsx +++ b/frontend/src/pages/404.tsx @@ -2,7 +2,7 @@ import { useSetPageTitle } from '@/utils' import { NonIdealState } from '@blueprintjs/core' const FourOhFour = () => { - useSetPageTitle('Page not Found') + useSetPageTitle('Page not found') return ( <NonIdealState title='Page not found' icon='warning-sign'> diff --git a/frontend/src/pages/analyst/_layout.tsx b/frontend/src/pages/analyst/_layout.tsx index 351a8afb9d4c12d5f263f1eefef46bc285322b36..2fc70e22de2b9f11dbcc69b817eed066584e521d 100644 --- a/frontend/src/pages/analyst/_layout.tsx +++ b/frontend/src/pages/analyst/_layout.tsx @@ -1,8 +1,9 @@ -import { useStaffBoundary } from '@/utils' +import { useSetPageTitle, useStaffBoundary } from '@/utils' import { Outlet } from 'react-router-dom' const Layout = () => { useStaffBoundary() + useSetPageTitle('Analyst') return <Outlet /> } diff --git a/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/email/_layout.tsx b/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/email/_layout.tsx index 4ed7e655186a910dbe5e20e3b8558a39487b5929..252a49b3df44d12f31980e450af7af225505063e 100644 --- a/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/email/_layout.tsx +++ b/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/email/_layout.tsx @@ -1,7 +1,6 @@ import { EmailSelection } from '@/analyst/utilities' import InstructorTeamEmails from '@/email/TeamEmails/InstructorTeamEmails' import { useNavigate, useParams } from '@/router' -import { useSetPageTitle } from '@/utils' import { useGetEmailThreads } from '@inject/graphql/queries/GetEmailThreads.generated' import notEmpty from '@inject/shared/utils/notEmpty' @@ -9,7 +8,6 @@ const Layout = () => { const { exerciseId, teamId, tab, channelId, threadId } = useParams( '/instructor/:exerciseId/:teamId/:channelId/email/:tab/:threadId' ) - useSetPageTitle(`Team ${teamId} - Emails`) const nav = useNavigate() const { data: emailThreadsData } = useGetEmailThreads({ diff --git a/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/form/[actionLogId].tsx b/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/form/[actionLogId].tsx index 37b9e71b7f5ece24df5e526bae1cccdeac6eccd4..7189b64621102364964acfafce9b51bc3f6edab0 100644 --- a/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/form/[actionLogId].tsx +++ b/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/form/[actionLogId].tsx @@ -1,17 +1,26 @@ -import { useParams } from '@/router' +import { useNavigate, useParams } from '@/router' import InjectMessageView from '@/views/InjectMessageView' const Page = () => { const { teamId, exerciseId, channelId, actionLogId } = useParams( '/instructor/:exerciseId/:teamId/:channelId/form/:actionLogId' ) + const nav = useNavigate() return ( <InjectMessageView teamId={teamId} exerciseId={exerciseId} - channelId={channelId} actionLogId={actionLogId} + onBack={() => + nav(`/instructor/:exerciseId/:teamId/:channelId/form`, { + params: { + teamId, + exerciseId, + channelId, + }, + }) + } /> ) } diff --git a/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/info/[actionLogId].tsx b/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/info/[actionLogId].tsx index 0f5e66c679b03b2295e7fc7233ca956415515d0e..4453c58b4b4fc3a3af6e1bb31ef653863dcfdf80 100644 --- a/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/info/[actionLogId].tsx +++ b/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/info/[actionLogId].tsx @@ -1,17 +1,26 @@ -import { useParams } from '@/router' +import { useNavigate, useParams } from '@/router' import InjectMessageView from '@/views/InjectMessageView' const Page = () => { const { teamId, exerciseId, channelId, actionLogId } = useParams( '/instructor/:exerciseId/:teamId/:channelId/info/:actionLogId' ) + const nav = useNavigate() return ( <InjectMessageView teamId={teamId} exerciseId={exerciseId} - channelId={channelId} actionLogId={actionLogId} + onBack={() => + nav(`/instructor/:exerciseId/:teamId/:channelId/info`, { + params: { + teamId, + exerciseId, + channelId, + }, + }) + } /> ) } diff --git a/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/tool/[actionLogId].tsx b/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/tool/[actionLogId].tsx index 6bfc38d6848e3b2b57cef7e1c5423162441becc2..7877894995a913390ac859ada6f55f78ba17912d 100644 --- a/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/tool/[actionLogId].tsx +++ b/frontend/src/pages/instructor/[exerciseId]/[teamId]/[channelId]/tool/[actionLogId].tsx @@ -1,17 +1,26 @@ -import { useParams } from '@/router' +import { useNavigate, useParams } from '@/router' import InjectMessageView from '@/views/InjectMessageView' const Page = () => { const { teamId, exerciseId, channelId, actionLogId } = useParams( '/instructor/:exerciseId/:teamId/:channelId/tool/:actionLogId' ) + const nav = useNavigate() return ( <InjectMessageView teamId={teamId} exerciseId={exerciseId} - channelId={channelId} actionLogId={actionLogId} + onBack={() => + nav(`/instructor/:exerciseId/:teamId/:channelId/tool`, { + params: { + teamId, + exerciseId, + channelId, + }, + }) + } /> ) } diff --git a/frontend/src/pages/instructor/_layout.tsx b/frontend/src/pages/instructor/_layout.tsx index 1129364df61e41395bc81308d633d391a20a229c..2a8289ba34e758f0cd3c65bf3ce18324d01c182f 100644 --- a/frontend/src/pages/instructor/_layout.tsx +++ b/frontend/src/pages/instructor/_layout.tsx @@ -1,11 +1,12 @@ import { useParams } from '@/router' -import { useStaffBoundary } from '@/utils' +import { useSetPageTitle, useStaffBoundary } from '@/utils' import InstructorView from '@/views/InstructorView' import { Outlet } from 'react-router-dom' const Layout = () => { const { exerciseId, teamId } = useParams('/instructor/:exerciseId/:teamId') useStaffBoundary() + useSetPageTitle('Instructor') return ( <InstructorView exerciseId={exerciseId} teamId={teamId}> diff --git a/frontend/src/pages/instructor/index.tsx b/frontend/src/pages/instructor/index.tsx index 16312bd878045651460cdd42dfa6aaaaf516c20b..4c07a7831c2c6bfa02bb91486125275ef60fd918 100644 --- a/frontend/src/pages/instructor/index.tsx +++ b/frontend/src/pages/instructor/index.tsx @@ -1,16 +1,11 @@ -import { useSetPageTitle } from '@/utils' import { NonIdealState } from '@blueprintjs/core' -const InstructorIndexPage = () => { - useSetPageTitle('Instructor') - - return ( - <NonIdealState - icon='search' - title='No teams selected' - description='Select teams to interact with' - /> - ) -} +const InstructorIndexPage = () => ( + <NonIdealState + icon='search' + title='No teams selected' + description='Select teams to interact with' + /> +) export default InstructorIndexPage diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 2b6d0d42d492c1561a93b2bb1dfb4336126a0b4d..50c13f000607cc774b291718cc9275017e60a82c 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,18 +1,23 @@ import InjectLogo from '@/assets/inject-logo--vertical-black.svg?react' import Login from '@/logic/Login' +import { useSetPageTitle } from '@/utils' import Container from '@inject/shared/components/Container' -const LoginPage = () => ( - <Container> - <InjectLogo - style={{ - width: '100%', - height: '300px', - margin: 'auto', - }} - /> - <Login /> - </Container> -) +const LoginPage = () => { + useSetPageTitle('Login') + + return ( + <Container> + <InjectLogo + style={{ + width: '100%', + height: '300px', + margin: 'auto', + }} + /> + <Login /> + </Container> + ) +} export default LoginPage diff --git a/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/email/_layout.tsx b/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/email/_layout.tsx index 9deebfe9d6a76d0835a1c2ed49a0bd873012442b..d0a15810936cbba6914d9e7776664ec0202b0068 100644 --- a/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/email/_layout.tsx +++ b/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/email/_layout.tsx @@ -1,7 +1,6 @@ import { EmailSelection } from '@/analyst/utilities' import TraineeTeamEmails from '@/email/TeamEmails/TraineeTeamEmails' import { useNavigate, useParams } from '@/router' -import { useSetPageTitle } from '@/utils' import { useGetEmailThreads } from '@inject/graphql/queries/GetEmailThreads.generated' import notEmpty from '@inject/shared/utils/notEmpty' @@ -9,7 +8,6 @@ const Layout = () => { const { exerciseId, teamId, tab, channelId, threadId } = useParams( '/trainee/:exerciseId/:teamId/:channelId/email/:tab/:threadId' ) - useSetPageTitle(`Team ${teamId} - Emails`) const nav = useNavigate() const { data: emailThreadsData } = useGetEmailThreads({ diff --git a/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/form/[actionLogId].tsx b/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/form/[actionLogId].tsx index 030bda6db75161c812cc6f9f9cb7d65cddfe983d..ebf9cff61ca456b1404b6b283d278886f5639c25 100644 --- a/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/form/[actionLogId].tsx +++ b/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/form/[actionLogId].tsx @@ -1,17 +1,26 @@ -import { useParams } from '@/router' +import { useNavigate, useParams } from '@/router' import InjectMessageView from '@/views/InjectMessageView' const Page = () => { const { teamId, exerciseId, channelId, actionLogId } = useParams( '/trainee/:exerciseId/:teamId/:channelId/form/:actionLogId' ) + const nav = useNavigate() return ( <InjectMessageView teamId={teamId} exerciseId={exerciseId} - channelId={channelId} actionLogId={actionLogId} + onBack={() => + nav(`/trainee/:exerciseId/:teamId/:channelId/form`, { + params: { + teamId, + exerciseId, + channelId, + }, + }) + } /> ) } diff --git a/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/info/[actionLogId].tsx b/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/info/[actionLogId].tsx index 500f9f2815ef3f355777e9171827ea1a0c7ce7fa..9681b94eea4f8d65880a7de64832562f83b931a5 100644 --- a/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/info/[actionLogId].tsx +++ b/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/info/[actionLogId].tsx @@ -1,17 +1,26 @@ -import { useParams } from '@/router' +import { useNavigate, useParams } from '@/router' import InjectMessageView from '@/views/InjectMessageView' const Page = () => { const { teamId, exerciseId, channelId, actionLogId } = useParams( '/trainee/:exerciseId/:teamId/:channelId/info/:actionLogId' ) + const nav = useNavigate() return ( <InjectMessageView teamId={teamId} exerciseId={exerciseId} - channelId={channelId} actionLogId={actionLogId} + onBack={() => + nav(`/trainee/:exerciseId/:teamId/:channelId/info`, { + params: { + teamId, + exerciseId, + channelId, + }, + }) + } /> ) } diff --git a/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/tool/[actionLogId].tsx b/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/tool/[actionLogId].tsx index 6d5b6dbefa7497c068fffe5a4c6085f4e4ef1f24..e1df6f4fad6baeb3c9d4493e75b72aaa5c811fd0 100644 --- a/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/tool/[actionLogId].tsx +++ b/frontend/src/pages/trainee/[exerciseId]/[teamId]/[channelId]/tool/[actionLogId].tsx @@ -1,17 +1,26 @@ -import { useParams } from '@/router' +import { useNavigate, useParams } from '@/router' import InjectMessageView from '@/views/InjectMessageView' const Page = () => { const { teamId, exerciseId, channelId, actionLogId } = useParams( '/trainee/:exerciseId/:teamId/:channelId/tool/:actionLogId' ) + const nav = useNavigate() return ( <InjectMessageView teamId={teamId} exerciseId={exerciseId} - channelId={channelId} actionLogId={actionLogId} + onBack={() => + nav(`/trainee/:exerciseId/:teamId/:channelId/tool`, { + params: { + teamId, + exerciseId, + channelId, + }, + }) + } /> ) } diff --git a/frontend/src/pages/trainee/_layout.tsx b/frontend/src/pages/trainee/_layout.tsx index 931d1ee5b998fbd9331c9c47f16485c8b555b60d..e2ae62bb40557703a9bd53f168d21c45241cdb2f 100644 --- a/frontend/src/pages/trainee/_layout.tsx +++ b/frontend/src/pages/trainee/_layout.tsx @@ -1,9 +1,11 @@ import { useParams } from '@/router' +import { useSetPageTitle } from '@/utils' import TraineeView from '@/views/TraineeView' import { Outlet } from 'react-router-dom' const Layout = () => { const { exerciseId, teamId } = useParams('/trainee/:exerciseId/:teamId') + useSetPageTitle('Trainee') return ( <TraineeView exerciseId={exerciseId} teamId={teamId}> diff --git a/frontend/src/pages/users/_layout.tsx b/frontend/src/pages/users/_layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b72fa7ef516545626480aa84266bbafa69c6e812 --- /dev/null +++ b/frontend/src/pages/users/_layout.tsx @@ -0,0 +1,10 @@ +import { useSetPageTitle } from '@/utils' +import { Outlet } from 'react-router-dom' + +const Layout = () => { + useSetPageTitle('User management') + + return <Outlet /> +} + +export default Layout diff --git a/frontend/src/pages/users/index.tsx b/frontend/src/pages/users/index.tsx index bae1aef41f5cf226bb1a8174e3adc1e0970c93c0..39aaf4ef2c1525ed9b232236b28337d73f0b63d1 100644 --- a/frontend/src/pages/users/index.tsx +++ b/frontend/src/pages/users/index.tsx @@ -1,10 +1,5 @@ import UserTable from '@/users/UserTable' -import { useSetPageTitle } from '@/utils' -const UserManagementIndexPage = () => { - useSetPageTitle('User management') - - return <UserTable /> -} +const UserManagementIndexPage = () => <UserTable /> export default UserManagementIndexPage diff --git a/frontend/src/views/ChannelButton.tsx b/frontend/src/views/ChannelButton.tsx index c05949f21689ab4f2deb0f579dd5813d4436ddd8..114feedc8f6a2b353fac3403efaa0971cf01d561 100644 --- a/frontend/src/views/ChannelButton.tsx +++ b/frontend/src/views/ChannelButton.tsx @@ -2,11 +2,12 @@ import { EmailSelection } from '@/analyst/utilities' import type { LinkType } from '@/components/LinkButton' import LinkButton from '@/components/LinkButton' import type { Path } from '@/router' -import type { IconName } from '@blueprintjs/core' +import { Colors, type IconName } from '@blueprintjs/core' +import { Dot } from '@blueprintjs/icons' import type { Channel } from '@inject/graphql/fragments/Channel.generated' import { useWriteReadReceiptChannel } from '@inject/graphql/mutations/clientonly/WriteReadReceiptChannel.generated' import type { ChannelType } from '@inject/graphql/types' -import type { FC } from 'react' +import { useMemo, type FC } from 'react' import { matchPath } from 'react-router-dom' interface ChannelButtonProps { @@ -131,24 +132,30 @@ const ChannelButton: FC<ChannelButtonProps> = ({ ) } + const isUnread = useMemo( + () => + channel.readReceipt.find( + ({ readReceipt, teamId: receiptTeamId }) => + receiptTeamId === teamId && readReceipt === null + ), + [channel.readReceipt, teamId] + ) + return ( <LinkButton key={getLink(channel)[0] as string} link={getLink(channel)} button={{ icon: getIcon(channel.type), - text: !hideLabel && channel.name, title: channel.name, fill: true, alignText: 'left', minimal: true, active: getActive(channel), - intent: channel.readReceipt?.find( - x => x?.teamId == teamId && x.readReceipt === null - ) - ? 'warning' - : undefined, + intent: isUnread ? 'warning' : undefined, + rightIcon: isUnread ? <Dot color={Colors.RED3} /> : undefined, onClick: mutate, + children: !hideLabel && isUnread ? <b>{channel.name}</b> : channel.name, }} /> ) diff --git a/frontend/src/views/InjectMessageView/index.tsx b/frontend/src/views/InjectMessageView/index.tsx index d735551b3c55cb3cae48c6187778fc3b36163895..c1b429db4eef87f8ef56b85fbb669bae8ddb8700 100644 --- a/frontend/src/views/InjectMessageView/index.tsx +++ b/frontend/src/views/InjectMessageView/index.tsx @@ -1,6 +1,5 @@ import InjectMessage from '@/actionlog/InjectMessage' import ErrorMessage from '@/components/ErrorMessage' -import { useNavigate } from '@/router' import { Button } from '@blueprintjs/core' import { css } from '@emotion/css' import { useGetSingleActionLog } from '@inject/graphql/queries/GetSingleActionLog.generated' @@ -19,22 +18,21 @@ const wrapper = css` interface InjectMessageViewProps { teamId: string exerciseId: string - channelId: string actionLogId: string + onBack: () => void } const InjectMessageView: FC<InjectMessageViewProps> = ({ teamId, exerciseId, - channelId, actionLogId, + onBack, }) => { const { data } = useGetSingleActionLog({ variables: { logId: actionLogId, }, }) - const nav = useNavigate() const inInstructor = useInInstructor() if (!data || !data.actionLog) { @@ -49,18 +47,7 @@ const InjectMessageView: FC<InjectMessageViewProps> = ({ text='Back' minimal style={{ marginBottom: '0.25rem' }} - onClick={() => - nav( - `/${inInstructor ? 'instructor' : 'trainee'}/:exerciseId/:teamId/:channelId/info`, - { - params: { - teamId, - exerciseId, - channelId, - }, - } - ) - } + onClick={onBack} /> <InjectMessage exerciseId={exerciseId} diff --git a/graphql/client/resolvers.ts b/graphql/client/resolvers.ts index 2b2e9241ad91f9fda3cfed2d335edae1ed7e1932..aa76639b27db22a1234f02869a3e5304d40dc873 100644 --- a/graphql/client/resolvers.ts +++ b/graphql/client/resolvers.ts @@ -48,7 +48,7 @@ const resolvers: Resolvers = { const item = localStorage.getItem( await getKey(id, getKeyContext(variables.instructor)) ) - const emailDraft = JSON.parse(item || '') as EmailDraftType + const emailDraft = JSON.parse(item || '{}') as EmailDraftType return { __typename: 'EmailDraftType', ...emailDraft, diff --git a/shared/document/plugins/pdf/PDFControls.tsx b/shared/document/plugins/pdf/PDFControls.tsx index f9b033a713e51a75aee6fda056b14747534d1420..bd7d93abd28f596e10b2b6d41e87bf6d2a9259db 100644 --- a/shared/document/plugins/pdf/PDFControls.tsx +++ b/shared/document/plugins/pdf/PDFControls.tsx @@ -1,5 +1,4 @@ -import { Button } from '@blueprintjs/core' -import { useTranslation } from '@cyntler/react-doc-viewer/dist/esm/hooks/useTranslation' +import { Button, ButtonGroup, Card, Divider } from '@blueprintjs/core' import { PDFContext } from '@cyntler/react-doc-viewer/dist/esm/renderers/pdf/state' import { setPDFPaginated, @@ -9,95 +8,76 @@ import { css } from '@emotion/css' import { useContext } from 'react' import PDFPagination from './PDFPagination' -const pdfsticky = css` - position: sticky; - top: 0.5rem; - left: 0; - right: 0; - z-index: 4; - padding-left: 0.5rem; - padding-right: 0.5rem; - overflow: visible; -` - const pdfcontrol = css` display: flex; - gap: 0.5rem; - justify-content: flex-end; - align-items: center; - background-color: white; + justify-content: space-between; padding: 0.5rem; - border-radius: 0.5rem; - box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.2); +` + +const buttonGroup = css` + display: flex; + justify-content: center; +` - .bp5-dark & { - background-color: #2f343c; - } +const viewToggle = css` + display: flex; + gap: 1rem; + align-items: center; ` const PDFControls = () => { - const { t } = useTranslation() const { - state: { - mainState, - paginated, - zoomLevel, - numPages, - zoomJump, - defaultZoomLevel, - }, + state: { paginated, zoomLevel, numPages, zoomJump, defaultZoomLevel }, dispatch, } = useContext(PDFContext) - const currentDocument = mainState?.currentDocument || null - return ( - <div className={pdfsticky}> - <div id='pdf-controls' className={pdfcontrol}> - {paginated && numPages > 1 && <PDFPagination />} - - {currentDocument?.fileData && ( + <Card id='pdf-controls' className={pdfcontrol}> + <div className={viewToggle}> + {numPages > 1 && ( <Button - id='pdf-download' - onClick={() => { - const link = document.createElement('a') - link.href = currentDocument?.fileData as string - link.download = currentDocument?.fileName || currentDocument?.uri - link.dispatchEvent(new MouseEvent('click')) - }} - title={t('downloadButtonLabel')} - icon='cloud-download' + id='pdf-toggle-pagination' + onMouseDown={() => dispatch(setPDFPaginated(!paginated))} + icon={paginated ? 'sort' : 'duplicate'} + minimal + text={ + paginated + ? 'Switch to continuos scroll' + : 'Switch to paginated view' + } + title={ + paginated + ? 'Switch to continuous scroll' + : 'Switch to paginated view' + } /> )} + {paginated && numPages > 1 && <PDFPagination />} + </div> + <ButtonGroup minimal className={buttonGroup}> <Button id='pdf-zoom-out' onMouseDown={() => dispatch(setZoomLevel(zoomLevel - zoomJump))} icon='zoom-out' + title='Zoom out' /> - <Button id='pdf-zoom-in' onMouseDown={() => dispatch(setZoomLevel(zoomLevel + zoomJump))} icon='zoom-in' + title='Zoom in' /> - + <Divider /> <Button id='pdf-zoom-reset' onMouseDown={() => dispatch(setZoomLevel(defaultZoomLevel))} disabled={zoomLevel === defaultZoomLevel} icon='zoom-to-fit' + title='Zoom to fit' /> - - {numPages > 1 && ( - <Button - id='pdf-toggle-pagination' - onMouseDown={() => dispatch(setPDFPaginated(!paginated))} - icon={paginated ? 'header' : 'document'} - /> - )} - </div> - </div> + </ButtonGroup> + </Card> ) } diff --git a/shared/document/plugins/pdf/PDFPages.tsx b/shared/document/plugins/pdf/PDFPages.tsx index f2aa05796b4e12c218ebf862ce0d8c2cb915e7e4..cd594909c459029c8d73b9d16939d41fac3efd2d 100644 --- a/shared/document/plugins/pdf/PDFPages.tsx +++ b/shared/document/plugins/pdf/PDFPages.tsx @@ -9,8 +9,13 @@ import PDFAllPages from './PDFAllPages' import PDFSinglePage from './PDFSinglePage' const pdfpage = css` + flex: 1; + overflow-y: auto; display: flex; flex-direction: column; +` + +const documentComponent = css` margin: 0 auto; ` @@ -30,16 +35,18 @@ const PDFPages = () => { if (!currentDocument || currentDocument.fileData === undefined) return null return ( - <Document - className={pdfpage} - file={currentDocument.fileData} - externalLinkTarget='_blank' - externalLinkRel='noopener noreferrer' - onLoadSuccess={({ numPages }) => dispatch(setNumPages(numPages))} - loading={<span>{t('pdfPluginLoading')}</span>} - > - {paginated ? <PDFSinglePage /> : <PDFAllPages />} - </Document> + <div className={pdfpage}> + <Document + className={documentComponent} + file={currentDocument.fileData} + externalLinkTarget='_blank' + externalLinkRel='noopener noreferrer' + onLoadSuccess={({ numPages }) => dispatch(setNumPages(numPages))} + loading={<span>{t('pdfPluginLoading')}</span>} + > + {paginated ? <PDFSinglePage /> : <PDFAllPages />} + </Document> + </div> ) } diff --git a/shared/document/plugins/pdf/PDFPagination.tsx b/shared/document/plugins/pdf/PDFPagination.tsx index 428914d5061438a3199b6c4324d8a9958b3a6374..8cb106e55d877b3c967b265f87a6c59aa86a9e0e 100644 --- a/shared/document/plugins/pdf/PDFPagination.tsx +++ b/shared/document/plugins/pdf/PDFPagination.tsx @@ -2,8 +2,16 @@ import { Button } from '@blueprintjs/core' import { useTranslation } from '@cyntler/react-doc-viewer/dist/esm/hooks/useTranslation' import { PDFContext } from '@cyntler/react-doc-viewer/dist/esm/renderers/pdf/state' import { setCurrentPage } from '@cyntler/react-doc-viewer/dist/esm/renderers/pdf/state/actions' +import { css } from '@emotion/css' import { useContext } from 'react' +const pdfPagination = css` + display: flex; + gap: 1rem; + justify-content: center; + align-items: center; +` + const PDFPagination = () => { const { state: { currentPage, numPages }, @@ -12,12 +20,14 @@ const PDFPagination = () => { const { t } = useTranslation() return ( - <> + <div className={pdfPagination}> <Button id='pdf-pagination-prev' onClick={() => dispatch(setCurrentPage(currentPage - 1))} disabled={currentPage === 1} icon='chevron-left' + title='Previous page' + minimal /> <span id='pdf-pagination-info'> @@ -32,8 +42,10 @@ const PDFPagination = () => { onClick={() => dispatch(setCurrentPage(currentPage + 1))} disabled={currentPage >= numPages} icon='chevron-right' + title='Next page' + minimal /> - </> + </div> ) } diff --git a/shared/document/plugins/pdf/index.tsx b/shared/document/plugins/pdf/index.tsx index e567a352f5ad96a2e32fd71c6aabc16e9022b69b..3520abd8526022abe048c53a470d746b4c3576c0 100644 --- a/shared/document/plugins/pdf/index.tsx +++ b/shared/document/plugins/pdf/index.tsx @@ -17,6 +17,7 @@ const container = css` flex-direction: column; flex: 1; overflow-y: auto; + gap: 1rem; ` pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker diff --git a/shared/document/viewer.tsx b/shared/document/viewer.tsx index daa56ec47127eb7e761597647ef307bd4518eaf8..1d40dd9a70dc5ea90472b4b93a76de26745b1a12 100644 --- a/shared/document/viewer.tsx +++ b/shared/document/viewer.tsx @@ -81,7 +81,11 @@ const DocViewerComponent: FC<DocViewerProps> = ({ doc }) => { </a> </ButtonGroup> </div> - <DocViewer documents={docs} pluginRenderers={plugins} /> + <DocViewer + documents={docs} + pluginRenderers={plugins} + config={{ pdfVerticalScrollByDefault: true }} + /> </div> ) }