From 57e70e2a355c622d167772a034510fc18258aeda Mon Sep 17 00:00:00 2001 From: Yatharth Bhargava <52095139+yatharth-b@users.noreply.github.com> Date: Thu, 14 Mar 2024 12:36:06 +0530 Subject: [PATCH 1/3] Error Modal after Accepting Invitations (#311) Add modals which tell the user if an error has occurred while accepting the invite. --- .../InvitationAcceptModal.tsx | 163 ++++++++++++++---- .../InvitationAcceptModal/stylesheet.scss | 36 +++- src/components/InviteBackLink/index.tsx | 66 ++++--- 3 files changed, 198 insertions(+), 67 deletions(-) diff --git a/src/components/InvitationAcceptModal/InvitationAcceptModal.tsx b/src/components/InvitationAcceptModal/InvitationAcceptModal.tsx index 18f41ae2..274f87df 100644 --- a/src/components/InvitationAcceptModal/InvitationAcceptModal.tsx +++ b/src/components/InvitationAcceptModal/InvitationAcceptModal.tsx @@ -7,6 +7,7 @@ import useLocalStorageState from 'use-local-storage-state'; import Button from '../Button'; import Modal from '../Modal'; import InvitationModal from '../InvitationModal'; +import LoginModal from '../LoginModal'; import './stylesheet.scss'; @@ -25,6 +26,7 @@ export default function InvitationAcceptModal({ const [modalOpen, setModalOpen] = useState(false); const [invitationModalOpen, setInvitationModalOpen] = useState(false); + const [loginModalOpen, setLoginModalOpen] = useState(false); const [searchParams] = useSearchParams(); const [hasSeen, setHasSeen] = useLocalStorageState( @@ -63,47 +65,134 @@ export default function InvitationAcceptModal({ }} inputEmail={searchParams.get('email') ?? undefined} /> - + + { + setLoginModalOpen(false); + }} + /> + + { + onHide(); + setLoginModalOpen(true); + }, + }, + ] + : undefined + } + > -
-
- {searchParams.get('status') === 'success' - ? 'You have successfully added a new schedule to your view!' - : 'Failed to add the schedule, please ask the user for a new invite.'} -
- {searchParams.get('status') === 'success' ? ( - <> -
- You will now be able to see {searchParams.get('email')}'s - schedule! -
- - ok -
-
Would you like to share your schedule back?
- -
- - -
-
- - ) : null} -
+ {searchParams.get('status') === 'success' ? ( + + ) : ( + + )}
); } + +type SuccessContentProps = { + email: string; + onHide: () => void; + setInvitationModalOpen: React.Dispatch>; +}; + +function SuccessContent({ + email, + onHide, + setInvitationModalOpen, +}: SuccessContentProps): React.ReactElement { + return ( +
+
+ You have successfully added a new schedule to your view! +
+
+ You will now be able to see {email}'s schedule! +
+ + ok +
+
Would you like to share your schedule back?
+ +
+ + +
+
+
+ ); +} + +type FailureContentProps = { + error: string; +}; + +function FailureContent({ error }: FailureContentProps): React.ReactElement { + return ( +
+ buzz +
Failed to add new schedule
+
+ {error === 'invalid-invite' + ? 'Invalid Invite' + : error === 'invite-expired' + ? 'Invite Expired' + : error === 'not-logged-in' + ? 'Not Logged In' + : "Something's wrong here.."} +
+
+ {error === 'invalid-invite' ? ( + + The invite request is invalid, please ask the user for a new + invite. + + ) : error === 'invite-expired' ? ( + + The invite request has expired, please ask the user for a new + invite. + + ) : error === 'not-logged-in' ? ( + + Login and click on the invite link again to add your friend's + schedule to your view. + + ) : ( + + An unknown error occurred on our end, please ask the user for a new + invite! + + )} +
+
+ ); +} diff --git a/src/components/InvitationAcceptModal/stylesheet.scss b/src/components/InvitationAcceptModal/stylesheet.scss index 242a66d2..07b3b1be 100644 --- a/src/components/InvitationAcceptModal/stylesheet.scss +++ b/src/components/InvitationAcceptModal/stylesheet.scss @@ -1,10 +1,12 @@ +@import '../../variables.scss'; + .invitation-accept-modal-content { .heading { font-size: 24px; color: white; - font-family: "Roboto"; + font-family: 'Roboto'; text-align: center; - font-weight: 600; + font-weight: 700; } .modal-image { @@ -12,6 +14,17 @@ padding-top: 20px; } + .error-sub-heading { + color: white; + font-size: 18px; + padding: 10px; + font-weight: 700; + } + + .error-message { + text-align: center; + } + .sub-heading { color: white; font-size: 14px; @@ -28,7 +41,7 @@ justify-content: center; padding-top: 10px; column-gap: 20px; - + button { width: 150px; outline: inherit; @@ -41,7 +54,7 @@ .no-button { border-radius: 6px; background: var(--action-button-secondary, #676767); - color: #FFF; + color: #fff; text-align: center; font-family: Roboto; font-size: 14px; @@ -52,24 +65,31 @@ .share-button { border-radius: 6px; - background: #FE5B53; - color: #FFF; + background: #fe5b53; + color: #fff; text-align: center; font-family: Roboto; font-size: 14px; font-style: normal; font-weight: 700; } + } + .buzz-image { + width: 200px; + height: 200px; + margin: 10px; } + + padding: 5%; padding-left: 10%; padding-right: 10%; display: flex; flex-direction: column; align-items: center; - font-family: "Roboto"; + font-family: 'Roboto'; } .remove-close-button { @@ -81,4 +101,4 @@ padding: 15px; color: #808080; border-radius: 50px; -} \ No newline at end of file +} diff --git a/src/components/InviteBackLink/index.tsx b/src/components/InviteBackLink/index.tsx index 25a4d407..407e2d83 100644 --- a/src/components/InviteBackLink/index.tsx +++ b/src/components/InviteBackLink/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import axios from 'axios'; +import axios, { AxiosError, AxiosResponse } from 'axios'; import useFirebaseAuth from '../../data/hooks/useFirebaseAuth'; import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; @@ -20,6 +20,16 @@ type HandleInvitationResponse = { email: string; }; +interface ServerError extends AxiosError { + response: ServerErrorResponse; +} + +interface ServerErrorResponse extends AxiosResponse { + data: { + message: string; + }; +} + const url = `${CLOUD_FUNCTION_BASE_URL}/handleFriendInvitation`; const handleInvite = async ( @@ -57,34 +67,46 @@ export default function InviteBackLink(): React.ReactElement { const accountContext = useFirebaseAuth(); useEffect(() => { - const handleInviteAsync = async (): Promise => { + const handleInviteAsync = async (): Promise => { if (accountContext.type === 'loaded') { const token = await (accountContext.result as SignedIn).getToken(); - if (id && navigate) { - handleInvite(id, token) - .then((email) => { - setState(LoadingState.SUCCESS); - navigate( - `${redirectURL}?email=${email}&status=success&inviteId=${id}` - ); - }) - .catch(() => { - setState(LoadingState.ERROR); - - navigate( - `${redirectURL}?email=none&status=failure&inviteId=${id}` - ); - }); - } + return handleInvite(id, token); } + return undefined; }; const { type } = accountContext; - if (type === 'loaded' && accountContext.result.type === 'signedIn') { - handleInviteAsync().catch((err) => { - console.error('Error handling invite', err); // eslint-disable-line no-console - }); + if ( + type === 'loaded' && + accountContext.result.type === 'signedIn' && + redirectURL !== undefined + ) { + handleInviteAsync() + .then((email) => { + setState(LoadingState.SUCCESS); + navigate( + `${redirectURL}?email=${email ?? ''}&status=success&inviteId=${ + id ?? '' + }` + ); + }) + .catch((err: ServerError) => { + setState(LoadingState.ERROR); + navigate( + `${redirectURL}?email=none&status=${ + err.response?.data.message ?? '' + }&inviteId=${id ?? ''}` + ); + }); + } else if ( + type === 'loaded' && + accountContext.result.type !== 'signedIn' && + redirectURL !== undefined + ) { + navigate( + `${redirectURL}?email=none&status=not-logged-in&inviteId=${id ?? ''}` + ); } }, [id, navigate, redirectURL, accountContext.type]); // eslint-disable-line react-hooks/exhaustive-deps From 2cc88dac670b244cc4a1063a17d0eba0a849d22f Mon Sep 17 00:00:00 2001 From: aeluro1 <103622874+aeluro1@users.noreply.github.com> Date: Fri, 22 Mar 2024 21:12:24 -0400 Subject: [PATCH 2/3] Updated shared schedule deletion API calls (#297) ### Updated shared schedule deletion API calls Resolves #282 --------- Co-authored-by: Yatharth Bhargava --- src/components/ComparisonContainer/index.tsx | 60 +++++++++----------- src/components/InvitationModal/index.tsx | 10 ++-- src/types.ts | 24 ++++++++ 3 files changed, 57 insertions(+), 37 deletions(-) diff --git a/src/components/ComparisonContainer/index.tsx b/src/components/ComparisonContainer/index.tsx index 6e7b7ecc..4048e373 100644 --- a/src/components/ComparisonContainer/index.tsx +++ b/src/components/ComparisonContainer/index.tsx @@ -31,6 +31,7 @@ import { ErrorWithFields, softError } from '../../log'; import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; import InvitationModal from '../InvitationModal'; import ComparisonContainerShareBack from '../ComparisonContainerShareBack/ComparisonContainerShareBack'; +import { ScheduleDeletionRequest } from '../../types'; import './stylesheet.scss'; @@ -151,13 +152,14 @@ export default function ComparisonContainer({ async (senderId: string, versions: string[]) => { const data = JSON.stringify({ IDToken: await (accountContext as SignedIn).getToken(), - senderId, + peerUserId: senderId, term, versions, - }); + owner: false, + } as ScheduleDeletionRequest); axios .post( - `${CLOUD_FUNCTION_BASE_URL}/deleteInvitationFromFriend`, + `${CLOUD_FUNCTION_BASE_URL}/deleteSharedSchedule`, `data=${data}`, { headers: { @@ -166,18 +168,7 @@ export default function ComparisonContainer({ } ) .catch((err) => { - softError( - new ErrorWithFields({ - message: 'delete sender record failed', - source: err, - fields: { - user: (accountContext as SignedIn).id, - sender: senderId, - term, - versions, - }, - }) - ); + throw err; }); }, [accountContext, term] @@ -220,25 +211,28 @@ export default function ComparisonContainer({ const handleRemoveSchedule = useCallback( (id: string, ownerId: string) => { - updateFriendTermData((draft) => { - if (draft.accessibleSchedules[ownerId]?.length === 1) { - delete draft.accessibleSchedules[ownerId]; - } else { - draft.accessibleSchedules[ownerId] = - draft.accessibleSchedules[ownerId]?.filter( - (schedule) => schedule !== id - ) ?? []; - } - }); - const newColorMap = { ...colorMap }; - delete newColorMap[id]; - patchSchedule({ colorMap: newColorMap }); - setSelected(selected.filter((selectedId: string) => selectedId !== id)); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - deleteInvitation(ownerId, [id]); + deleteInvitation(ownerId, [id]) + .then(() => { + setSelected( + selected.filter((selectedId: string) => selectedId !== id) + ); + }) + .catch((err) => { + softError( + new ErrorWithFields({ + message: 'delete sender record failed', + source: err, + fields: { + user: (accountContext as SignedIn).id, + sender: ownerId, + term, + versions: [id], + }, + }) + ); + }); }, - [selected, colorMap, updateFriendTermData, patchSchedule, deleteInvitation] + [selected, deleteInvitation, accountContext, term] ); const handleToggleSchedule = useCallback( diff --git a/src/components/InvitationModal/index.tsx b/src/components/InvitationModal/index.tsx index d036bced..124ab32d 100644 --- a/src/components/InvitationModal/index.tsx +++ b/src/components/InvitationModal/index.tsx @@ -20,6 +20,7 @@ import Modal from '../Modal'; import Button from '../Button'; import { AccountContext, SignedIn } from '../../contexts/account'; import { ErrorWithFields, softError } from '../../log'; +import { ScheduleDeletionRequest } from '../../types'; import './stylesheet.scss'; @@ -137,13 +138,14 @@ export function InvitationModalContent({ deleteFriendRecord(currentVersion, friendId); const data = JSON.stringify({ IDToken: await (accountContext as SignedIn).getToken(), - friendId, + peerUserId: friendId, term, - version: currentVersion, - }); + versions: currentVersion, + owner: true, + } as ScheduleDeletionRequest); axios .post( - `${CLOUD_FUNCTION_BASE_URL}/deleteInvitationFromSender`, + `${CLOUD_FUNCTION_BASE_URL}/deleteSharedSchedule`, `data=${data}`, { headers: { diff --git a/src/types.ts b/src/types.ts index 2302a8bb..f496416c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -354,3 +354,27 @@ export interface CrawlerTermData { */ version: number; } + +export type ScheduleDeletionRequest = { + /** + * token of account that requested the schedule deletion + */ + IDToken: string | void; + /** + * ID of the INVITEE if the deletion requester is the INVITER + * ID of the INVITER if the deletion requester is the INVITEE + */ + peerUserId: string; + /** + * term that schedule version(s) belong to + */ + term: string; + /** + * shared schedule version(s) for deletion + */ + versions: string[] | string; + /** + * whether the schedule version belongs to the requester + */ + owner: boolean; +}; From e5933ec3098bb2ebfdc448f1c8e0bd37dd45c716 Mon Sep 17 00:00:00 2001 From: Yatharth Bhargava <52095139+yatharth-b@users.noreply.github.com> Date: Sat, 23 Mar 2024 12:37:33 +0530 Subject: [PATCH 3/3] Refactor Delete Friend Functions Compare Panel (#312) --- src/components/ComparisonContainer/index.tsx | 119 ++++++++++--------- src/constants.ts | 4 +- 2 files changed, 64 insertions(+), 59 deletions(-) diff --git a/src/components/ComparisonContainer/index.tsx b/src/components/ComparisonContainer/index.tsx index 4048e373..8d7cecb8 100644 --- a/src/components/ComparisonContainer/index.tsx +++ b/src/components/ComparisonContainer/index.tsx @@ -90,8 +90,7 @@ export default function ComparisonContainer({ { deleteVersion, renameVersion, patchSchedule }, ] = useContext(ScheduleContext); - const [{ friends }, { updateFriendTermData, renameFriend }] = - useContext(FriendContext); + const [{ friends }, { renameFriend }] = useContext(FriendContext); const accountContext = useContext(AccountContext); @@ -148,7 +147,7 @@ export default function ComparisonContainer({ setEditValue(''); }, [editInfo, editValue, renameFriend, renameVersion]); - const deleteInvitation = useCallback( + const deleteSchedulesFromInvitee = useCallback( async (senderId: string, versions: string[]) => { const data = JSON.stringify({ IDToken: await (accountContext as SignedIn).getToken(), @@ -157,82 +156,88 @@ export default function ComparisonContainer({ versions, owner: false, } as ScheduleDeletionRequest); - axios - .post( - `${CLOUD_FUNCTION_BASE_URL}/deleteSharedSchedule`, - `data=${data}`, - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ) - .catch((err) => { - throw err; - }); + + const friend = friends[senderId]; + if (friend) { + axios + .post( + `${CLOUD_FUNCTION_BASE_URL}/deleteSharedSchedule`, + `data=${data}`, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ) + .then(() => { + const newColorMap = { ...colorMap }; + versions.forEach((schedule) => { + delete newColorMap[schedule]; + }); + setSelected( + selected.filter( + (selectedId: string) => + !Object.keys(friend.versions).includes(selectedId) + ) + ); + patchSchedule({ colorMap: newColorMap }); + // updateFriendTermData((draft) => { + // delete draft.accessibleSchedules[senderId]; + // }); + }) + .catch((err) => { + throw err; + }); + } }, - [accountContext, term] + [accountContext, term, colorMap, friends, patchSchedule, selected] ); + // remove all versions of a particular friend from user (invitee) view const handleRemoveFriend = useCallback( (ownerId: string) => { const friend = friends[ownerId]; if (friend) { - const newColorMap = { ...colorMap }; - const versions = Object.keys(friend.versions); - versions.forEach((schedule) => { - delete newColorMap[schedule]; - }); - setSelected( - selected.filter( - (selectedId: string) => - !Object.keys(friend.versions).includes(selectedId) - ) - ); - patchSchedule({ colorMap: newColorMap }); - updateFriendTermData((draft) => { - delete draft.accessibleSchedules[ownerId]; - }); // eslint-disable-next-line @typescript-eslint/no-floating-promises - deleteInvitation(ownerId, versions); - } - }, - [ - friends, - selected, - colorMap, - patchSchedule, - updateFriendTermData, - deleteInvitation, - ] - ); - - const handleRemoveSchedule = useCallback( - (id: string, ownerId: string) => { - deleteInvitation(ownerId, [id]) - .then(() => { - setSelected( - selected.filter((selectedId: string) => selectedId !== id) - ); - }) - .catch((err) => { + deleteSchedulesFromInvitee(ownerId, versions).catch((err) => { softError( new ErrorWithFields({ - message: 'delete sender record failed', + message: 'Failed to delete user schedule', source: err, fields: { user: (accountContext as SignedIn).id, sender: ownerId, term, - versions: [id], + versions, }, }) ); }); + } + }, + [friends, deleteSchedulesFromInvitee, accountContext, term] + ); + + const handleRemoveSchedule = useCallback( + (id: string, ownerId: string) => { + deleteSchedulesFromInvitee(ownerId, [id]).catch((err) => { + softError( + new ErrorWithFields({ + message: 'Failed to delete user schedule', + source: err, + fields: { + user: (accountContext as SignedIn).id, + sender: ownerId, + term, + versions: [id], + }, + }) + ); + }); }, - [selected, deleteInvitation, accountContext, term] + [deleteSchedulesFromInvitee, accountContext, term] ); const handleToggleSchedule = useCallback( diff --git a/src/constants.ts b/src/constants.ts index 7d5fa46e..7c9b4373 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -76,8 +76,8 @@ const CAMPUSES: Record = { const BACKEND_BASE_URL = 'https://gt-scheduler.azurewebsites.net'; const FIREBASE_PROJECT_ID = firebaseConfig.projectId || `gt-scheduler-web-dev`; -const CLOUD_FUNCTION_BASE_URL = `https://us-central1-${FIREBASE_PROJECT_ID}.cloudfunctions.net`; -// const CLOUD_FUNCTION_BASE_URL = `http://127.0.0.1:5001/${FIREBASE_PROJECT_ID}/us-central1`; +// const CLOUD_FUNCTION_BASE_URL = `https://us-central1-${FIREBASE_PROJECT_ID}.cloudfunctions.net`; +const CLOUD_FUNCTION_BASE_URL = `http://127.0.0.1:5001/${FIREBASE_PROJECT_ID}/us-central1`; const LARGE_DESKTOP_BREAKPOINT = 1200; const DESKTOP_BREAKPOINT = 1024;