diff --git a/src/App.jsx b/src/App.jsx index fc36107..6ac4a5e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,7 +5,6 @@ import { CookiesProvider } from 'react-cookie'; import Login from './components/Authentication/Login'; import Logout from './components/Authentication/Logout'; import SignUp from './components/Authentication/SignUp'; -import Notifications from './pages/Notifications/Notifications'; import ForgotPassword from './components/Authentication/ForgotPassword'; import EmailAction from './components/Authentication/EmailAction'; import AUTH_ROLES from './utils/auth_config'; @@ -14,6 +13,7 @@ import Catalog from './pages/Catalog/Catalog'; import PublishedSchedule from './pages/PublishedSchedule/PublishedSchedule'; import Playground from './pages/Playground/Playground'; import Planner from './pages/Planner/Planner'; +import NotificationSandbox from './pages/NotificationSandbox/NotificationSandbox'; const { ADMIN_ROLE, USER_ROLE } = AUTH_ROLES.AUTH_ROLES; @@ -40,13 +40,9 @@ const App = () => { } /> + } /> { + const toast = useToast(); + const [accounts, setAccounts] = useState(notificationBlock.getNotificationData().accounts); + const [disableChildrenButtons, setDisableChildrenButtons] = useState(false); + + const blockDate = notificationBlock.getDate(); + const diffTime = today - blockDate; + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + const acceptAll = async accounts => { + await Promise.all( + accounts.map(async account => { + await account.approveCallback(); + }), + ); + + toast({ + title: `Approved ${accounts?.length} accounts.`, + status: 'success', + duration: 9000, + isClosable: true, + }); + + removeEntry(notificationBlock.key); + }; + + const declineAll = async accounts => { + await Promise.all( + accounts.map(async account => { + await account.declineCallback(); + }), + ); + + toast({ + title: `Declined ${accounts?.length} accounts.`, + status: 'info', + duration: 9000, + isClosable: true, + }); + removeEntry(notificationBlock.key); + }; + + return ( + +
+ {accounts?.length === 1 ? ( + <> + {/* No accordion for 1 account notification */} + + + {accounts?.[0]?.email}{' '} + + + is requesting account approval... + + + { + acceptAll(accounts); + }} + declineCallback={() => { + declineAll(accounts); + }} + pl="3.25rem" + /> + + ) : ( + <> + {/* Accordion for >1 account notification in block */} + + + {accounts?.[0]?.email} and {accounts?.length - 1} other + {accounts?.length - 1 > 1 && 's'}{' '} + + + {accounts?.length - 1 > 1 ? 'are' : 'is'} requesting account approval... + + + + + + + + + { + setDisableChildrenButtons(true); + acceptAll(accounts); + }} + declineCallback={() => { + setDisableChildrenButtons(true); + declineAll(accounts); + }} + /> + + + + {accounts?.map(({ id, email, approveCallback, declineCallback }) => ( + + + + {email} + + + + { + await approveCallback(); + toast({ + title: `Approved ${email}.`, + status: 'success', + duration: 9000, + isClosable: true, + }); + setAccounts(accounts => + accounts.filter(account => account.id !== id), + ); + }} + declineCallback={async () => { + await declineCallback(); + toast({ + title: `Declined ${email}.`, + status: 'info', + duration: 9000, + isClosable: true, + }); + setAccounts(accounts => + accounts.filter(account => account.id !== id), + ); + }} + disableChildrenButtons={disableChildrenButtons} + /> + + + ))} + + + + + + )} +
+ + {diffDays > 0 ? ( + <> + {diffDays} day{diffDays !== 1 && 's'} ago + + ) : ( + <>Today + )} + +
+ ); +}; +AccountNotification.propTypes = { + notificationBlock: PropTypes.instanceOf(AccountNotificationBlock), + today: PropTypes.instanceOf(Date), + removeEntry: PropTypes.func, +}; + +const AccountButtonGroup = ({ + acceptText, + acceptCallback, + declineText, + declineCallback, + disableChildrenButtons, + ...chakraProps +}) => { + const [acceptState, setAcceptState] = useState(undefined); + const [declineState, setDeclineState] = useState(undefined); + + return ( + + + + + ); +}; +AccountButtonGroup.propTypes = { + acceptText: PropTypes.string, + acceptCallback: PropTypes.func, + declineText: PropTypes.string, + declineCallback: PropTypes.func, + disableChildrenButtons: PropTypes.bool, + chakraProps: PropTypes.any, +}; + +export default AccountNotification; diff --git a/src/components/Notifications/EventNotification.jsx b/src/components/Notifications/EventNotification.jsx new file mode 100644 index 0000000..0f83369 --- /dev/null +++ b/src/components/Notifications/EventNotification.jsx @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import { EventNotificationBlock } from './NotificationElement'; +import { Container, Text } from '@chakra-ui/react'; + +const EventNotification = ({ notificationBlock, today }) => { + const { title, status } = notificationBlock.getNotificationData(); + + const blockDate = notificationBlock.getDate(); + const diffTime = today - blockDate; + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + return ( + + + + Event: {title} + + + {status === 'added' && ' was added to the schedule.'} + {status === 'confirmed' && ' was changed from tentative to confirmed.'} + + + + {diffDays > 0 ? ( + <> + {diffDays} day{diffDays !== 1 && 's'} ago + + ) : ( + <>Today + )} + + + ); +}; + +EventNotification.propTypes = { + notificationBlock: PropTypes.instanceOf(EventNotificationBlock), + today: PropTypes.instanceOf(Date), +}; + +export default EventNotification; diff --git a/src/components/Notifications/NotificationElement.js b/src/components/Notifications/NotificationElement.js new file mode 100644 index 0000000..a0d0967 --- /dev/null +++ b/src/components/Notifications/NotificationElement.js @@ -0,0 +1,59 @@ +class NotificationBlock { + constructor(date, type, key) { + this.date = date; // Date + this.type = type; // 'account' | 'event' + this.key = key; + } + + getDate() { + return this.date; + } + + getType() { + return this.type; + } + + getKey() { + return this.key; + } + + getNotificationData() { + // Abstract method must be implemented by sub-class + // Returns data to display for notification; format of data depends on + // the type of notification block (account vs event) + } +} + +class AccountNotificationBlock extends NotificationBlock { + constructor(date, type, key) { + super(date, type, key); + this.pendingAccounts = []; + } + + addPendingAccount(id, email, approveCallback, declineCallback) { + this.pendingAccounts.push({ + id: id, + email: email, + approveCallback: approveCallback, + declineCallback: declineCallback, + }); + } + + getNotificationData() { + return { accounts: this.pendingAccounts }; + } +} + +class EventNotificationBlock extends NotificationBlock { + constructor(date, type, key, title, status) { + super(date, type, key); + this.title = title; + this.status = status; // 'added' | 'confirmed' + } + + getNotificationData() { + return { title: this.title, status: this.status }; + } +} + +export { AccountNotificationBlock, EventNotificationBlock }; diff --git a/src/components/Notifications/Notifications.jsx b/src/components/Notifications/Notifications.jsx new file mode 100644 index 0000000..59668b8 --- /dev/null +++ b/src/components/Notifications/Notifications.jsx @@ -0,0 +1,147 @@ +import { Table, Tbody, Tr, Td, TableContainer } from '@chakra-ui/react'; +import { useEffect, useState, useMemo } from 'react'; +import { NPOBackend } from '../../utils/auth_utils'; +import { AccountNotificationBlock, EventNotificationBlock } from './NotificationElement'; +import AccountNotification from './AccountNotification'; +import EventNotification from './EventNotification'; + +const getDateFromISOString = isoString => { + const dateObject = new Date(isoString); + const formattedDateString = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(dateObject); + + return { dateObject, formattedDateString }; +}; + +const Notifications = () => { + const [notificationList, setNotificationList] = useState([]); + const today = useMemo(() => new Date(), []); + + const approveAccount = async id => { + try { + console.log('Approving', id); + await NPOBackend.put(`/users/approve/${id}`); + console.log('Approved', id); + } catch (e) { + console.log(e); + } + }; + + const declineAccount = async id => { + try { + console.log('Declining', id); + await NPOBackend.delete(`/users/${id}`); + console.log('Declined', id); + } catch (e) { + console.log(e); + } + }; + + const removeNotificationEntry = key => { + setNotificationList(notificationBlocks => + notificationBlocks.filter(block => block.getKey() !== key), + ); + }; + + useEffect(() => { + const fetchNotificationData = async () => { + const [pendingAccounts, addedEvents, confirmedEvents] = await Promise.all([ + NPOBackend.get(`/users/pending-accounts`).then(res => res.data), + NPOBackend.get('/published-schedule/recently-added').then(res => res.data), + NPOBackend.get('/published-schedule/recently-confirmed').then(res => res.data), + ]); + + // Map MM-DD-YY string to AccountNotificationBlock + const accountsMap = new Map(); + + pendingAccounts.forEach(({ approvedOn, email, id }) => { + const { dateObject, formattedDateString } = getDateFromISOString(approvedOn); + let notificationBlock; + + if (!accountsMap.has(formattedDateString)) { + // Create notification date block if first found entry of that day + const key = `accounts-${formattedDateString}`; + notificationBlock = new AccountNotificationBlock(dateObject, 'account', key); + } else { + // Set notification block to be existing entry + notificationBlock = accountsMap.get(formattedDateString); + } + + notificationBlock.addPendingAccount( + id, + email, + async () => { + await approveAccount(id); + }, + async () => { + await declineAccount(id); + }, + ); + accountsMap.set(formattedDateString, notificationBlock); + }); + + // Map MM-DD-YY to EventNotificationBlock + const eventsArray = []; + + addedEvents.forEach(({ createdOn, title, id }) => { + const { dateObject } = getDateFromISOString(createdOn); + const key = `event-added-${id}`; + eventsArray.push(new EventNotificationBlock(dateObject, 'event', key, title, 'added')); + }); + + confirmedEvents.forEach(({ confirmedOn, title, id }) => { + const { dateObject } = getDateFromISOString(confirmedOn); + const key = `event-confirmed-${id}`; + eventsArray.push(new EventNotificationBlock(dateObject, 'event', key, title, 'confirmed')); + }); + + const notificationBlocks = [...Array.from(accountsMap.values()), ...eventsArray]; + + // Sort notification blocks in reverse chronological order, + // treating every block as NotificationBlock (base class) object + notificationBlocks.sort((notificationBlock1, notificationBlock2) => { + const date1 = notificationBlock1.getDate(); + const date2 = notificationBlock2.getDate(); + + return date2 - date1; + }); + + setNotificationList(notificationBlocks); + }; + fetchNotificationData().catch(console.error); + }, []); + + return ( + + + + {notificationList.map(notificationBlock => { + const notificationType = notificationBlock.getType(); + + return ( + + + + ); + })} + +
+ {notificationType === 'account' && ( + + )} + {notificationType === 'event' && ( + + )} +
+
+ ); +}; + +export default Notifications; diff --git a/src/pages/NotificationSandbox/NotificationSandbox.jsx b/src/pages/NotificationSandbox/NotificationSandbox.jsx new file mode 100644 index 0000000..a9dd6c9 --- /dev/null +++ b/src/pages/NotificationSandbox/NotificationSandbox.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { + Button, + Container, + Drawer, + DrawerBody, + DrawerHeader, + DrawerOverlay, + DrawerContent, + DrawerCloseButton, + useDisclosure, +} from '@chakra-ui/react'; +import Notifications from '../../components/Notifications/Notifications'; +import { BellIcon } from '@chakra-ui/icons'; + +const NotificationSandbox = () => { + const { isOpen, onOpen, onClose } = useDisclosure(); + const buttonRef = React.useRef(); + + return ( +
+

Notification Sandbox

+ {/* TODO: Move Notification button and drawer to navbar once layout component is pushed */} + <> + + + + + + + Notifications + + + + + + + +
+ ); +}; + +export default NotificationSandbox; diff --git a/src/pages/Notifications/Notifications.jsx b/src/pages/Notifications/Notifications.jsx deleted file mode 100644 index ab20cf9..0000000 --- a/src/pages/Notifications/Notifications.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import { - Table, - Thead, - Tbody, - Tr, - Th, - Td, - TableContainer, - Link, -} from '@chakra-ui/react' -import { useEffect, useState } from 'react'; -import { NPOBackend } from '../../utils/auth_utils'; - -const Notifications = () => { - const [listData, setListData] = useState([]); - - useEffect(() => { - const getPendingUsers = async () => { - const response = await NPOBackend.get(`/users/pending-accounts`); - setListData(response.data); - }; - getPendingUsers().catch(console.error); - }, []); - - const ApproveAll = async () => { - try { - listData.map(async user => { - await NPOBackend.put(`/users/approve/${user.id}`); - }); - - location.reload(); - } catch (e) { - console.log(e); - } - } - - const DeclineAll = async () => { - try { - listData.map(async user => { - await NPOBackend.delete(`/users/${user.id}`); - }); - - location.reload(); - } catch (e) { - console.log(e); - } - } - - const Approve = async (id) => { - try { - await NPOBackend.put(`/users/approve/${id}`); - location.reload(); - } catch (e) { - console.log(e); - } - - } - - const Decline = async (id) => { - try { - await NPOBackend.delete(`/users/${id}`); - location.reload(); - } catch (e) { - console.log(e); - } - } - - return ( - - - - - - - - - - - - - {listData.map(({ id, email }) => ( - - - - - - - ))} - -
IDEmail { await ApproveAll() }}>Approve All { await DeclineAll() }}>Decline All
{id}{email} { await Approve(id)} }>Accept { await Decline(id)} }>Decline
-
- ); -}; - -export default Notifications;