Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Functionality to Invitation Modal #186

Open
wants to merge 8 commits into
base: bog-changes-s23
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/components/AppDataLoader/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,12 +286,17 @@ function ContextProvider({
}, [termScheduleData.versions]);

// Get all version-related actions
const { addNewVersion, deleteVersion, renameVersion, cloneVersion } =
useVersionActions({
updateTermScheduleData,
setVersion,
currentVersion,
});
const {
addNewVersion,
deleteVersion,
renameVersion,
cloneVersion,
deleteFriendRecord,
} = useVersionActions({
updateTermScheduleData,
setVersion,
currentVersion,
});

// Memoize the context values so that they are stable
const scheduleContextValue = useMemo<ScheduleContextValue>(
Expand All @@ -301,6 +306,7 @@ function ContextProvider({
oscar,
currentVersion,
allVersionNames,
currentFriends: scheduleVersion.friends ?? {},
...castDraft(scheduleVersion.schedule),
},
{
Expand All @@ -310,6 +316,7 @@ function ContextProvider({
setCurrentVersion: setVersion,
addNewVersion,
deleteVersion,
deleteFriendRecord,
renameVersion,
cloneVersion,
},
Expand All @@ -319,12 +326,14 @@ function ContextProvider({
oscar,
currentVersion,
allVersionNames,
scheduleVersion.friends,
scheduleVersion.schedule,
setTerm,
patchSchedule,
updateSchedule,
setVersion,
addNewVersion,
deleteFriendRecord,
deleteVersion,
renameVersion,
cloneVersion,
Expand Down
1 change: 0 additions & 1 deletion src/components/AppDataLoader/stages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,6 @@ export function StageLoadRawScheduleDataFromFirebase({
children,
}: StageLoadRawScheduleDataFromFirebaseProps): React.ReactElement {
const loadingState = useRawScheduleDataFromFirebase(accountState);

if (loadingState.type !== 'loaded') {
return (
<AppSkeleton {...skeletonProps}>
Expand Down
16 changes: 9 additions & 7 deletions src/components/HeaderActionBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,15 @@ export default function HeaderActionBar({
onClick: onCopyCrns,
});
}
exportActions.push({
label: 'Share Schedule',
icon: faShare,
onClick: (): void => {
setInvitationOpen(true);
},
});
if (accountState.type === 'signedIn') {
exportActions.push({
label: 'Share Schedule',
icon: faShare,
onClick: (): void => {
setInvitationOpen(true);
},
});
}

// On small mobile screens and on large desktop,
// left-anchor the "Export" dropdown.
Expand Down
197 changes: 77 additions & 120 deletions src/components/InvitationModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,137 +1,97 @@
import React, {
ChangeEvent,
KeyboardEvent,
useCallback,
useMemo,
useContext,
useState,
useRef,
} from 'react';
import { Tooltip as ReactTooltip } from 'react-tooltip';
import { faCircle, faClose } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import axios, { AxiosError } from 'axios';

import { ApiErrorResponse } from '../../data/types';
import { ScheduleContext } from '../../contexts';
import { classes } from '../../utils/misc';
import Modal from '../Modal';
import Button from '../Button';
import { AccountContext, SignedIn } from '../../contexts/account';
import { CLOUD_FUNCTION_BASE_URL } from '../../constants';

import './stylesheet.scss';

/**
* Inner content of the invitation modal.
*/
export function InvitationModalContent(): React.ReactElement {
// Array for testing style of shared emails
// eslint-disable-next-line
const [emails, setEmails] = useState([
['[email protected]', 'Pending'],
['[email protected]', 'Accepted'],
['[email protected]', 'Accepted'],
['[email protected]', 'Accepted'],
['[email protected]', 'Pending'],
['[email protected]', 'Accepted'],
['[email protected]', 'Accepted'],
['[email protected]', 'Accepted'],
]);
const [{ currentFriends, currentVersion, term }, { deleteFriendRecord }] =
useContext(ScheduleContext);
const accountContext = useContext(AccountContext);

// Array to test invalid email
const validUsers = [
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
];
const [input, setInput] = useState('');
const input = useRef<HTMLInputElement>(null);
const [validMessage, setValidMessage] = useState('');
const [validClassName, setValidClassName] = useState('');

// Boolean to hide and open search dropdown
const [hidden, setHidden] = useState(true);

// Array for testing dropdown of recent invites
// eslint-disable-next-line
const [recentInvites, setRecentInvites] = useState<string[]>([
'[email protected]',
'[email protected]',
]);
const [activeIndex, setActiveIndex] = useState(-1);

const handleChangeSearch = useCallback((e: ChangeEvent<HTMLInputElement>) => {
let search = e.target.value.trim();
const results = /^([A-Z]+)(\d.*)$/i.exec(search);
if (results != null) {
const [, email, number] = results as unknown as [string, string, string];
search = `${email}${number}`;
}
setHidden(false);
setInput(search);
const handleChangeSearch = useCallback(() => {
setValidMessage('');
setActiveIndex(-1);
setValidClassName('');
}, []);

const searchResults = useMemo(() => {
if (!input) return recentInvites;
const results = /^([A-Z]+) ?((\d.*)?)$/i.exec(input?.toUpperCase());
if (!results) {
return [];
}

return recentInvites.filter((invite) => {
const searchMatch = recentInvites.includes(invite);
return searchMatch;
const sendInvitation = useCallback(async (): Promise<void> => {
const IdToken = await (accountContext as SignedIn).getToken();
return axios.post(`${CLOUD_FUNCTION_BASE_URL}/createFriendInvitation`, {
term,
friendEmail: input.current?.value,
IDToken: IdToken,
version: currentVersion,
});
}, [input, recentInvites]);
}, [accountContext, currentVersion, term]);

// verify email with a regex and send invitation if valid
const verifyEmail = useCallback((): void => {
if (input.current && /^\S+@\S+\.\S+$/.test(input.current.value)) {
sendInvitation()
.then(() => {
setValidMessage('Successfully sent!');
setValidClassName('valid-email');
if (input.current) {
input.current.value = '';
}
})
.catch((err) => {
setValidClassName('invalid-email');
const error = err as AxiosError;
if (error.response) {
const apiError = error.response.data as ApiErrorResponse;
setValidMessage(apiError.message);
return;
}
setValidMessage('Error sending invitation. Please try again later.');
});
} else {
setValidMessage('Invalid Email');
setValidClassName('invalid-email');
}
}, [sendInvitation]);

const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
setHidden(false);
setInput(
searchResults[
Math.min(activeIndex + 1, searchResults.length - 1)
] as string
);
setActiveIndex(Math.min(activeIndex + 1, searchResults.length - 1));
break;
case 'ArrowUp':
setHidden(false);
setInput(searchResults[Math.max(activeIndex - 1, 0)] as string);
setActiveIndex(Math.max(activeIndex - 1, 0));
break;
case 'Escape':
setHidden(true);
break;
case 'Enter':
setHidden(true);
verifyEmail();
break;
default:
return;
}
e.preventDefault();
},
[searchResults, activeIndex]
[verifyEmail]
);

const handleCloseDropdown = useCallback(
(index?: number) => {
if (index !== undefined) {
setInput(searchResults[index] as string);
setActiveIndex(index);
}
setHidden(true);
},
[searchResults]
);

function verifyUser(): void {
if (validUsers.includes(input)) {
setValidMessage('Successfully sent!');
setValidClassName('valid-email');
setInput('');
} else {
setValidMessage('Invalid Email');
setValidClassName('invalid-email');
}
}
// delete friend from record of friends
const handleDelete = (friendId: string): void => {
deleteFriendRecord(currentVersion, friendId);
};

return (
<div className="invitation-modal-content">
Expand All @@ -148,33 +108,17 @@ export function InvitationModalContent(): React.ReactElement {
type="email"
id="email"
key="email"
value={input}
ref={input}
className="email"
placeholder="[email protected]"
list="recent-invites"
onChange={handleChangeSearch}
onFocus={handleChangeSearch}
onKeyDown={handleKeyDown}
onBlur={(): void => handleCloseDropdown()}
onChange={handleChangeSearch}
/>
{!hidden && (
<div id="recent-invites">
{searchResults.map((element, index) => (
<div
className={classes(
'search-option',
index === activeIndex && 'active'
)}
onMouseDown={(): void => handleCloseDropdown(index)}
>
{element}
</div>
))}
</div>
)}
<text className={validClassName}>{validMessage}</text>
</div>
<button type="button" className="send-button" onClick={verifyUser}>
<button type="button" className="send-button" onClick={verifyEmail}>
Send Invite
</button>
</div>
Expand All @@ -185,22 +129,35 @@ export function InvitationModalContent(): React.ReactElement {
Users Invited to View <strong>Primary</strong>
</p>
<div className="shared-emails" key="email">
{emails.map((element) => (
<div className="email-and-status" id={element[0]}>
<div className="individual-shared-email" id={element[1]}>
{element[0]}
<Button className="button-remove">
{Object.keys(currentFriends ?? {}).map((friend) => (
<div
className="email-and-status"
id={currentFriends[friend]?.email}
>
<div
className={classes(
'individual-shared-email',
currentFriends[friend]?.status
)}
>
{currentFriends[friend]?.email}
<Button
className="button-remove"
onClick={(): void => {
handleDelete(friend);
}}
>
<FontAwesomeIcon className="circle" icon={faCircle} />
<FontAwesomeIcon className="remove" icon={faClose} />
</Button>
<ReactTooltip
anchorId={element[0]}
anchorId={currentFriends[friend]?.email}
className="status-tooltip"
variant="dark"
place="top"
offset={2}
>
Status: {element[1]}
Status: {currentFriends[friend]?.status}
</ReactTooltip>
yatharth-b marked this conversation as resolved.
Show resolved Hide resolved
</div>
</div>
Expand Down
Loading