From e0f1f53c3824aef34e612857066d5b32f69d4a84 Mon Sep 17 00:00:00 2001 From: Michael Vo Date: Sun, 14 May 2023 18:45:39 +1000 Subject: [PATCH] feat(frontend): use react-hot-toast (#429) --- frontend/package.json | 1 + frontend/src/App.tsx | 62 ++++--------- frontend/src/components/Toast.tsx | 77 +++++++++++++++ frontend/src/hooks/useFetch.ts | 12 +-- .../AdminContent/AdminCampaignContent.tsx | 52 +++++------ .../src/pages/admin/AdminContent/index.tsx | 59 ++++++------ .../review/finalise_candidates/index.tsx | 14 +-- .../src/pages/admin/review/rankings/index.tsx | 12 +-- frontend/src/pages/auth_success/index.tsx | 3 +- frontend/src/pages/create_campaign/index.tsx | 93 ++++++++++--------- frontend/src/utils/{index.ts => index.tsx} | 13 +++ frontend/yarn.lock | 9 +- 12 files changed, 239 insertions(+), 168 deletions(-) create mode 100644 frontend/src/components/Toast.tsx rename frontend/src/utils/{index.ts => index.tsx} (82%) diff --git a/frontend/package.json b/frontend/package.json index 25e737ba..22b47476 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "14.2.3", + "react-hot-toast": "2.4.1", "react-router-dom": "6.4.3", "typescript": "5.0.4", "web-vitals": "3.1.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13d25c02..ddbe0483 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,14 @@ import { Box, CssBaseline, ThemeProvider, createTheme } from "@mui/material"; import { SnackbarProvider } from "notistack"; -import { Suspense, useCallback, useRef, useState } from "react"; +import { Suspense, useState } from "react"; +import { Toaster } from "react-hot-toast"; import { BrowserRouter, Routes } from "react-router-dom"; import "twin.macro"; -import { MessagePopupContext } from "contexts/MessagePopupContext"; - -import { LoadingIndicator, MessagePopup, NavBar } from "./components"; +import { LoadingIndicator, NavBar } from "./components"; import { SetNavBarTitleContext } from "./contexts/SetNavbarTitleContext"; import routes from "./routes"; -import type { Message } from "contexts/MessagePopupContext"; - const theme = createTheme({ palette: { primary: { @@ -37,47 +34,28 @@ const theme = createTheme({ const App = () => { const [AppBarTitle, setNavBarTitle] = useState(""); - const [messagePopup, setMessagePopup] = useState([]); - const nextMessageId = useRef(1); - - const pushMessage = useCallback( - (message: Omit) => { - setMessagePopup([ - ...messagePopup, - // eslint-disable-next-line no-plusplus - { ...message, id: nextMessageId.current++ }, - ]); - - setTimeout(() => { - setMessagePopup(messagePopup.slice(1)); - }, 5000); - }, - [setMessagePopup, messagePopup] - ); return ( - - - - - - }> - {routes} - - -
- {messagePopup.map((message) => ( - - {message.message} - - ))} -
-
-
-
+ + + + + }> + {routes} + + + + +
); diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 00000000..4348c8ea --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/restrict-template-expressions -- needed for target other components in stitches styles */ +import { Fragment } from "react"; +import toast from "react-hot-toast"; +import tw, { styled } from "twin.macro"; + +import Transition from "./Transition"; + +import type { VariantProps } from "@stitches/react"; +import type { Toast as ToastObject } from "react-hot-toast"; + +const ButtonContainer = styled("div", tw`border-l border-gray-200`); + +const ToastContainer = styled("div", { + ...tw`flex w-full max-w-md bg-white rounded border shadow`, + + variants: { + type: { + notification: { + [`&, & ${ButtonContainer}`]: tw`border-gray-200`, + }, + success: { + [`&, & ${ButtonContainer}`]: tw`border-green-200`, + "& h1": tw`text-green-600`, + }, + error: { + [`&, & ${ButtonContainer}`]: tw`border-red-200`, + "& h1": tw`text-red-600`, + }, + }, + }, +}); + +export type ToastType = Extract< + VariantProps["type"], + string +>; +type Props = { + t: ToastObject; + title: string; + description: string; + type?: ToastType; +}; +const Toast = ({ t, title, description, type = "notification" }: Props) => ( + + +
+

{title}

+

{description}

+
+ + + +
+
+); + +export default Toast; diff --git a/frontend/src/hooks/useFetch.ts b/frontend/src/hooks/useFetch.ts index 3ee9f67d..695aa47f 100644 --- a/frontend/src/hooks/useFetch.ts +++ b/frontend/src/hooks/useFetch.ts @@ -1,7 +1,6 @@ -import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; -import { MessagePopupContext } from "contexts/MessagePopupContext"; -import { getStore } from "utils"; +import { getStore, pushToast } from "utils"; import type { Json } from "types/api"; @@ -61,8 +60,6 @@ const useFetch = (url: string, options?: Options) => { const [abortBehaviour] = useState(options?.abortBehaviour ?? "all"); const controllers = useRef({}); - const pushMessage = useContext(MessagePopupContext); - const refetch = useCallback(() => { setRetry({}); }, []); @@ -141,10 +138,7 @@ const useFetch = (url: string, options?: Options) => { if (options?.errorSummary) { message = `${options.errorSummary}: ${message}`; } - pushMessage({ - type: "error", - message, - }); + pushToast("Error in fetch", message, "error"); } } finally { setLoading(false); diff --git a/frontend/src/pages/admin/AdminContent/AdminCampaignContent.tsx b/frontend/src/pages/admin/AdminContent/AdminCampaignContent.tsx index 34cd8581..70fe7fec 100644 --- a/frontend/src/pages/admin/AdminContent/AdminCampaignContent.tsx +++ b/frontend/src/pages/admin/AdminContent/AdminCampaignContent.tsx @@ -2,7 +2,7 @@ import { DeleteForeverRounded } from "@mui/icons-material"; import AddIcon from "@mui/icons-material/Add"; import EditIcon from "@mui/icons-material/Edit"; import { Divider, IconButton, ListItemIcon, ListItemText } from "@mui/material"; -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import "twin.macro"; @@ -11,8 +11,7 @@ import { FetchError } from "api/api"; import { Modal } from "components"; import Button from "components/Button"; import Dropzone from "components/Dropzone"; -import { MessagePopupContext } from "contexts/MessagePopupContext"; -import { dateToDateString } from "utils"; +import { dateToDateString, pushToast } from "utils"; import { AdminContentList, @@ -47,7 +46,6 @@ const AdminCampaignContent = ({ campaigns, setCampaigns, orgId }: Props) => { startDate: "", endDate: "", }); - const pushMessage = useContext(MessagePopupContext); useEffect(() => { if (coverImage === undefined) { @@ -81,10 +79,7 @@ const AdminCampaignContent = ({ campaigns, setCampaigns, orgId }: Props) => { message += "unknown error"; } - pushMessage({ - type: "error", - message, - }); + pushToast("Delete Campaign", message, "error"); throw e; } @@ -94,10 +89,7 @@ const AdminCampaignContent = ({ campaigns, setCampaigns, orgId }: Props) => { const uploadCoverImage = async () => { if (coverImage === undefined) { - pushMessage({ - message: "No organisation logo given.", - type: "error", - }); + pushToast("Update Campaign Cover Image", "No image given", "error"); return; } @@ -112,25 +104,28 @@ const AdminCampaignContent = ({ campaigns, setCampaigns, orgId }: Props) => { try { const data = (await err.resp.json()) as string; - pushMessage({ - message: `Internal Error: ${data}`, - type: "error", - }); + pushToast( + "Update Campaign Cover Image", + `Internal Error: ${data}`, + "error" + ); } catch { - pushMessage({ - message: `Internal Error: Response Invalid`, - type: "error", - }); + pushToast( + "Update Campaign Cover Image", + "Internal Error: Response Invalid", + "error" + ); } return; } console.error("Something went wrong"); - pushMessage({ - message: "Something went wrong on backend!", - type: "error", - }); + pushToast( + "Update Campaign Cover Image", + "Something went wrong on the backend!", + "error" + ); return; } @@ -141,10 +136,11 @@ const AdminCampaignContent = ({ campaigns, setCampaigns, orgId }: Props) => { ].image = newCoverImage; setCampaigns(newCampaigns); - pushMessage({ - message: "Updated campaign cover image", - type: "success", - }); + pushToast( + "Update Campaign Cover Image", + "Uploaded image succesfully", + "success" + ); }; return ( diff --git a/frontend/src/pages/admin/AdminContent/index.tsx b/frontend/src/pages/admin/AdminContent/index.tsx index f3864bf4..9f1c855d 100644 --- a/frontend/src/pages/admin/AdminContent/index.tsx +++ b/frontend/src/pages/admin/AdminContent/index.tsx @@ -4,13 +4,13 @@ import { Button, ToggleButton, ToggleButtonGroup } from "@mui/material"; import { useContext, useEffect, useState } from "react"; import "twin.macro"; +import { doDeleteOrg, putOrgLogo } from "api"; import { FetchError } from "api/api"; import { Modal } from "components"; import TwButton from "components/Button"; import Dropzone from "components/Dropzone"; -import { MessagePopupContext } from "contexts/MessagePopupContext"; +import { pushToast } from "utils"; -import { doDeleteOrg, putOrgLogo } from "../../../api"; import { OrgContext } from "../OrgContext"; import AdminCampaignContent from "./AdminCampaignContent"; @@ -61,12 +61,11 @@ const AdminContent = ({ const [imageSrc, setImageSrc] = useState(); const { orgSelected, setOrgSelected, orgList, setOrgList } = useContext(OrgContext); - const pushMessage = useContext(MessagePopupContext); useEffect(() => { if (orgLogo === undefined) { // have to be consistent in returning a function to make eslint happy - return () => {}; + return () => { }; } const reader = new FileReader(); @@ -95,10 +94,7 @@ const AdminContent = ({ message += "unknown error"; } - pushMessage({ - type: "error", - message, - }); + pushToast("Delete Organisation", message, "error"); throw e; } @@ -121,10 +117,11 @@ const AdminContent = ({ const uploadOrgLogo = async () => { if (orgLogo === undefined) { - pushMessage({ - message: "No organisation logo given.", - type: "error", - }); + pushToast( + "Update Organisation Logo", + "No organisation logo given", + "error" + ); return; } @@ -136,25 +133,28 @@ const AdminContent = ({ try { const data = (await err.resp.json()) as string; - pushMessage({ - message: `Internal Error: ${data}`, - type: "error", - }); + pushToast( + "Update Organisation Logo", + `Internal Error: ${data}`, + "error" + ); } catch { - pushMessage({ - message: `Internal Error: Response Invalid`, - type: "error", - }); + pushToast( + "Update Organisation Logo", + "Internal Error: Response Invalid", + "error" + ); } return; } - console.error("Something went wrong"); - pushMessage({ - message: "Something went wrong on backend!", - type: "error", - }); + console.error(err); + pushToast( + "Update Organisation Logo", + "Something went wrong on the backend!", + "error" + ); return; } @@ -163,10 +163,11 @@ const AdminContent = ({ newOrgList[newOrgList.findIndex((org) => org.id === id)].icon = newOrgLogo; setOrgList(newOrgList); - pushMessage({ - message: "Updated organisation logo", - type: "success", - }); + pushToast( + "Update Organisation Logo", + "Image uploaded successfully", + "success" + ); }; return ( diff --git a/frontend/src/pages/admin/review/finalise_candidates/index.tsx b/frontend/src/pages/admin/review/finalise_candidates/index.tsx index 96f2e870..ca574035 100644 --- a/frontend/src/pages/admin/review/finalise_candidates/index.tsx +++ b/frontend/src/pages/admin/review/finalise_candidates/index.tsx @@ -15,9 +15,9 @@ import { LoadingIndicator, ReviewerStepper } from "components"; import Button from "components/Button"; import Tabs from "components/Tabs"; import Textarea from "components/Textarea"; -import { MessagePopupContext } from "contexts/MessagePopupContext"; import { SetNavBarTitleContext } from "contexts/SetNavbarTitleContext"; import useFetch from "hooks/useFetch"; +import { pushToast } from "utils"; import { useRoles } from ".."; @@ -44,7 +44,6 @@ const FinaliseCandidates = () => { const roleId = Number(useParams().roleId); const roles = useRoles(); const [organisation, setOrganisation] = useState("ORGANISATION"); - const pushMessage = useContext(MessagePopupContext); const [emails, setEmails] = useState<{ [id: number]: string }>({}); @@ -176,12 +175,13 @@ const FinaliseCandidates = () => { const success = await Promise.all(tabs.map((_, i) => sendEmail(i))); if (success.every(Boolean)) { - pushMessage({ - type: "success", - message: "Updated all application statuses for role", - }); + pushToast( + "Update Status", + "Updated all application statuses for role", + "success" + ); } - }, [sendEmail, pushMessage]); + }, [sendEmail]); if (loading || orgLoading) { return ; diff --git a/frontend/src/pages/admin/review/rankings/index.tsx b/frontend/src/pages/admin/review/rankings/index.tsx index de81b759..9a565b77 100644 --- a/frontend/src/pages/admin/review/rankings/index.tsx +++ b/frontend/src/pages/admin/review/rankings/index.tsx @@ -12,9 +12,9 @@ import { } from "api"; import LoadingIndicator from "components/LoadingIndicator"; import ReviewerStepper from "components/ReviewerStepper"; -import { MessagePopupContext } from "contexts/MessagePopupContext"; import { SetNavBarTitleContext } from "contexts/SetNavbarTitleContext"; import useFetch from "hooks/useFetch"; +import { pushToast } from "utils"; import DragDropRankings from "./DragDropRankings"; @@ -55,7 +55,6 @@ const Rankings = () => { const [applications, setApplications] = useState({}); const [passIndex, setPassIndex] = useState(0); - const pushMessage = useContext(MessagePopupContext); const { put } = useFetch("/application", { abortBehaviour: "sameUrl", jsonResp: false, @@ -77,10 +76,11 @@ const Rankings = () => { ); if (success.every(Boolean)) { - pushMessage({ - type: "success", - message: "Updated internal application statuses for role", - }); + pushToast( + "Update status", + "Updated internal application statuses for role", + "success" + ); } }; diff --git a/frontend/src/pages/auth_success/index.tsx b/frontend/src/pages/auth_success/index.tsx index 9ca79d64..fa8ebd63 100644 --- a/frontend/src/pages/auth_success/index.tsx +++ b/frontend/src/pages/auth_success/index.tsx @@ -6,7 +6,7 @@ import { FetchError } from "api/api"; import { authenticate } from "../../api"; import { LoadingIndicator } from "../../components"; import useQuery from "../../hooks/useQuery"; -import { setStore } from "../../utils"; +import { pushToast, setStore } from "../../utils"; import { SIGNUP_REQUIRED } from "../../utils/constants"; import type { AuthenticateErrResponse, AuthenticateResponse } from "types/api"; @@ -65,6 +65,7 @@ const AuthSuccess = () => { if (needsSignup) { navigate("/signup"); } else if (isAuthenticated) { + pushToast("Authenticated", "Logged in successfully", "success"); navigate("/dashboard"); } }, [needsSignup, isAuthenticated]); diff --git a/frontend/src/pages/create_campaign/index.tsx b/frontend/src/pages/create_campaign/index.tsx index 0793ce46..5adfb637 100644 --- a/frontend/src/pages/create_campaign/index.tsx +++ b/frontend/src/pages/create_campaign/index.tsx @@ -1,12 +1,14 @@ import { Container, Tab, Tabs } from "@mui/material"; -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { + createCampaign, + isAdminInOrganisation, + setCampaignCoverImage, +} from "api"; import { FetchError } from "api/api"; -import { MessagePopupContext } from "contexts/MessagePopupContext"; -import { base64ToBytes, dateToStringForBackend } from "utils"; - -import { createCampaign, isAdminInOrganisation, setCampaignCoverImage } from "../../api"; +import { dateToStringForBackend, pushToast } from "utils"; import CampaignTab from "./Campaign"; import ReviewTab from "./Preview"; @@ -14,7 +16,6 @@ import RolesTab from "./Roles"; import { ArrowIcon, NextButton, NextWrapper } from "./createCampaign.styled"; import type { Answers, Question, Role } from "./types"; -import useFetch from "hooks/useFetch"; const CreateCampaign = () => { const orgId = Number(useParams().orgId); @@ -77,38 +78,36 @@ const CreateCampaign = () => { const rolesTabIdx = 1; const reviewTabIdx = 2; - const pushMessage = useContext(MessagePopupContext); - const onTabChange = (newTab: number) => { // only allow user to access review tab if all inputs are non-empty if (newTab === reviewTabIdx) { if (campaignName === "") { - pushMessage({ - message: "Campaign name is required!", - type: "error", - }); + pushToast("Create Campaign", "Campaign name is required!", "error"); return; } if (description === "") { - pushMessage({ - message: "Campaign description is required!", - type: "error", - }); + pushToast( + "Create Campaign", + "Campaign description is required!", + "error" + ); return; } if (cover === null) { - pushMessage({ - message: "Campaign cover image is required!", - type: "error", - }); + pushToast( + "Create Campaign", + "Campaign cover image is required!", + "error" + ); return; } if (roles.length === 0) { - pushMessage({ - message: "You need to create at least one role", - type: "error", - }); + pushToast( + "Create Campaign", + "You need to create at least one role", + "error" + ); return; } @@ -130,10 +129,11 @@ const CreateCampaign = () => { }); if (question.roles.size === 0) { - pushMessage({ - message: `The question '${question.text}' is not assigned to a role`, - type: "error", - }); + pushToast( + "Create Campaign", + `The question '${question.text}' is not assigned to a role`, + "error" + ); flag = false; } else { question.roles.forEach((roleId) => { @@ -150,10 +150,11 @@ const CreateCampaign = () => { const role = roleMap.get(roleID); if (role) { - pushMessage({ - message: `The role '${role.title}' does not have any questions`, - type: "error", - }); + pushToast( + "Create Campaign", + `The role '${role.title}' does not have any questions`, + "error" + ); } }); } @@ -238,25 +239,24 @@ const CreateCampaign = () => { try { const data = (await err.resp.json()) as string; - pushMessage({ - message: `Internal Error: ${data}`, - type: "error", - }); + pushToast("Create Campaign", `Internal Error: ${data}`, "error"); } catch { - pushMessage({ - message: `Internal Error: Response Invalid`, - type: "error", - }); + pushToast( + "Create Campaign", + "Internal Error: Response Invalid", + "error" + ); } return; } console.error("Something went wrong"); - pushMessage({ - message: "Something went wrong on backend!", - type: "error", - }); + pushToast( + "Create Campaign", + "Something went wrong on the backend!", + "error" + ); } }; @@ -275,7 +275,10 @@ const CreateCampaign = () => { {tab === campaignTabIdx && } {tab === rolesTabIdx && } {tab === reviewTabIdx && ( - + void submitHandler(e)} + /> )} {(tab === campaignTabIdx || tab === rolesTabIdx) && ( diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.tsx similarity index 82% rename from frontend/src/utils/index.ts rename to frontend/src/utils/index.tsx index 73f88ed0..e84bf86b 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.tsx @@ -1,4 +1,7 @@ +import { ToastType } from "components/Toast"; import moment from "moment"; +import { toast } from "react-hot-toast"; +import Toast from "components/Toast"; export function isLogin(): boolean { return true; @@ -45,3 +48,13 @@ export const getStore = (key: string) => localStorage.getItem(key); export const setStore = (key: string, val: string) => localStorage.setItem(key, val); export const removeStore = (key: string) => localStorage.removeItem(key); + +export const pushToast = ( + title: string, + description: string, + type?: ToastType +) => { + toast.custom((t) => ( + + )); +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 515846b7..57a78ad9 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3250,7 +3250,7 @@ globrex@^0.1.2: resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== -goober@^2.0.33: +goober@^2.0.33, goober@^2.1.10: version "2.1.13" resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c" integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ== @@ -4200,6 +4200,13 @@ react-dropzone@14.2.3: file-selector "^0.6.0" prop-types "^15.8.1" +react-hot-toast@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" + integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== + dependencies: + goober "^2.1.10" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"