diff --git a/editor.planx.uk/src/components/Toast/Toast.tsx b/editor.planx.uk/src/components/Toast/Toast.tsx new file mode 100644 index 0000000000..4b9219293f --- /dev/null +++ b/editor.planx.uk/src/components/Toast/Toast.tsx @@ -0,0 +1,28 @@ +import Alert from "@mui/material/Alert"; +import Snackbar from "@mui/material/Snackbar"; +import { useToast } from "hooks/useToast"; +import React from "react"; + +import { Toast as ToastProps } from "./types"; + +const Toast = ({ message, type = "success", id }: ToastProps) => { + const toast = useToast(); + + const handleCloseToast = () => { + if (toast) { + toast.remove(id); + } else { + console.warn("ToastContext is not provided."); + } + }; + + return ( + + + {message} + + + ); +}; + +export default Toast; diff --git a/editor.planx.uk/src/components/Toast/ToastContainer.tsx b/editor.planx.uk/src/components/Toast/ToastContainer.tsx new file mode 100644 index 0000000000..becd84d383 --- /dev/null +++ b/editor.planx.uk/src/components/Toast/ToastContainer.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +import Toast from "./Toast"; +import { Toast as ToastComponent } from "./types"; + +const ToastContainer = ({ toasts }: { toasts: ToastComponent[] }) => { + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +}; + +export default ToastContainer; diff --git a/editor.planx.uk/src/components/Toast/types.ts b/editor.planx.uk/src/components/Toast/types.ts new file mode 100644 index 0000000000..99c7e6d614 --- /dev/null +++ b/editor.planx.uk/src/components/Toast/types.ts @@ -0,0 +1,31 @@ +export interface Toast { + message: string; + type: ToastType; + id: number; +} +export type ToastType = "success" | "warning" | "info" | "error"; + +export type ToastState = { + toasts: Toast[]; +}; + +export type ToastAction = AddToast | DeleteToast; + +type AddToast = { + type: "ADD_TOAST"; + payload: Toast; +}; + +type DeleteToast = { + type: "DELETE_TOAST"; + payload: { id: number }; +}; + +export type ToastContextType = { + addToast: (type: ToastType, message: string) => void; + remove: (id: number) => void; + success: (message: string) => void; + warning: (message: string) => void; + info: (message: string) => void; + error: (message: string) => void; +}; diff --git a/editor.planx.uk/src/contexts/ToastContext.tsx b/editor.planx.uk/src/contexts/ToastContext.tsx new file mode 100644 index 0000000000..b340c8428c --- /dev/null +++ b/editor.planx.uk/src/contexts/ToastContext.tsx @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import ToastContainer from "components/Toast/ToastContainer"; +import { + ToastContextType, + ToastState, + ToastType, +} from "components/Toast/types"; +import React, { createContext, ReactNode, useReducer } from "react"; +import { toastReducer } from "reducers/toastReducer"; + +const defaultCreateContextValue = { + remove: (_id: number) => {}, + addToast: (_type: ToastType, _message: string) => {}, + success: (_message: string) => {}, + warning: (_message: string) => {}, + info: (_message: string) => {}, + error: (_message: string) => {}, +}; + +export const ToastContext = createContext( + defaultCreateContextValue, +); + +const initialState: ToastState = { + toasts: [], +}; + +export const ToastContextProvider = ({ + children, +}: Readonly<{ children: ReactNode }>) => { + const [state, dispatch] = useReducer(toastReducer, initialState); + const addToast = (type: ToastType, message: string) => { + const id = Math.floor(Math.random() * 10_000_000); + dispatch({ type: "ADD_TOAST", payload: { id, message, type } }); + }; + const remove = (id: number) => { + dispatch({ type: "DELETE_TOAST", payload: { id } }); + }; + const success = (message: string) => { + addToast("success", message); + }; + + const warning = (message: string) => { + addToast("warning", message); + }; + + const info = (message: string) => { + addToast("info", message); + }; + + const error = (message: string) => { + addToast("error", message); + }; + + const value: ToastContextType = { + success, + warning, + info, + error, + remove, + addToast, + }; + + return ( + + + {children} + + ); +}; diff --git a/editor.planx.uk/src/hooks/useToast.ts b/editor.planx.uk/src/hooks/useToast.ts new file mode 100644 index 0000000000..288cf41dcf --- /dev/null +++ b/editor.planx.uk/src/hooks/useToast.ts @@ -0,0 +1,5 @@ +import { useContext } from "react"; + +import { ToastContext } from "../contexts/ToastContext"; + +export const useToast = () => useContext(ToastContext); diff --git a/editor.planx.uk/src/index.tsx b/editor.planx.uk/src/index.tsx index cf87410d8e..b2c4b6b3ce 100644 --- a/editor.planx.uk/src/index.tsx +++ b/editor.planx.uk/src/index.tsx @@ -6,6 +6,7 @@ import { ApolloProvider } from "@apollo/client"; import CssBaseline from "@mui/material/CssBaseline"; import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"; import { MyMap } from "@opensystemslab/map"; +import { ToastContextProvider } from "contexts/ToastContext"; import { getCookie, setCookie } from "lib/cookie"; import ErrorPage from "pages/ErrorPage"; import { AnalyticsProvider } from "pages/FlowEditor/lib/analytics/provider"; @@ -93,7 +94,7 @@ const Layout: React.FC<{ }; root.render( - <> + @@ -109,5 +110,5 @@ root.render( - , + , ); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/DesignSettings/index.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/DesignSettings/index.tsx index 9215f9b95b..2f263733f0 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/DesignSettings/index.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/DesignSettings/index.tsx @@ -1,11 +1,10 @@ -import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; -import Snackbar from "@mui/material/Snackbar"; import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; import { TeamTheme } from "@opensystemslab/planx-core/types"; import { FormikConfig } from "formik"; +import { useToast } from "hooks/useToast"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useState } from "react"; import SettingsSection from "ui/editor/SettingsSection"; @@ -34,7 +33,7 @@ const DesignSettings: React.FC = () => { const [formikConfig, setFormikConfig] = useState< FormikConfig | undefined >(undefined); - + const toast = useToast(); /** * Fetch current team and setup shared form config */ @@ -60,20 +59,7 @@ const DesignSettings: React.FC = () => { fetchTeam(); }, []); - const [open, setOpen] = useState(false); - - const handleClose = ( - _event?: React.SyntheticEvent | Event, - reason?: string, - ) => { - if (reason === "clickaway") { - return; - } - - setOpen(false); - }; - - const onSuccess = () => setOpen(true); + const onSuccess = () => toast.success("Theme updated successfully"); return ( @@ -93,11 +79,6 @@ const DesignSettings: React.FC = () => { )} - - - Theme updated successfully - - ); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/GeneralSettings/index.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/GeneralSettings/index.tsx index c2c192e067..66db287dea 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/GeneralSettings/index.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/GeneralSettings/index.tsx @@ -1,9 +1,8 @@ -import Alert from "@mui/material/Alert"; import Container from "@mui/material/Container"; -import Snackbar from "@mui/material/Snackbar"; import Typography from "@mui/material/Typography"; import { TeamSettings } from "@opensystemslab/planx-core/types"; import { FormikConfig } from "formik"; +import { useToast } from "hooks/useToast"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useState } from "react"; import SettingsSection from "ui/editor/SettingsSection"; @@ -22,6 +21,7 @@ const GeneralSettings: React.FC = () => { const [formikConfig, setFormikConfig] = useState< FormikConfig | undefined >(undefined); + const toast = useToast(); useEffect(() => { const fetchTeam = async () => { @@ -44,21 +44,7 @@ const GeneralSettings: React.FC = () => { fetchTeam(); }, []); - const [open, setOpen] = useState(false); - const [updateMessage, _setUpdateMessage] = useState("Setting Updated"); - - const handleClose = ( - _event?: React.SyntheticEvent | Event, - reason?: string, - ) => { - if (reason === "clickaway") { - return; - } - - setOpen(false); - }; - - const onSuccess = () => setOpen(true); + const onSuccess = () => toast.success("Setting Updated"); return ( @@ -81,11 +67,6 @@ const GeneralSettings: React.FC = () => { )} - - - {updateMessage} - - ); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx index 59dc50e2ae..5e223c925d 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceSettings.tsx @@ -1,5 +1,4 @@ import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Container from "@mui/material/Container"; @@ -7,13 +6,13 @@ import FormControlLabel, { formControlLabelClasses, } from "@mui/material/FormControlLabel"; import Link from "@mui/material/Link"; -import Snackbar from "@mui/material/Snackbar"; import Switch, { SwitchProps } from "@mui/material/Switch"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { FlowStatus } from "@opensystemslab/planx-core/types"; import axios from "axios"; import { useFormik } from "formik"; +import { useToast } from "hooks/useToast"; import React, { useState } from "react"; import { rootFlowPath } from "routes/utils"; import { FONT_WEIGHT_BOLD } from "theme"; @@ -228,19 +227,7 @@ const ServiceSettings: React.FC = () => { state.teamDomain, state.isFlowPublished, ]); - - const [isAlertOpen, setIsAlertOpen] = useState(false); - - const handleClose = ( - _event?: React.SyntheticEvent | Event, - reason?: string, - ) => { - if (reason === "clickaway") { - return; - } - - setIsAlertOpen(false); - }; + const toast = useToast(); const sendFlowStatusSlackNotification = async (status: FlowStatus) => { const skipTeamSlugs = [ @@ -294,7 +281,7 @@ const ServiceSettings: React.FC = () => { }, onSubmit: async (values) => { await updateFlowSettings(values); - setIsAlertOpen(true); + toast.success("Service settings updated successfully"); }, validate: () => {}, }); @@ -306,7 +293,7 @@ const ServiceSettings: React.FC = () => { onSubmit: async (values, { resetForm }) => { const isSuccess = await updateFlowStatus(values.status); if (isSuccess) { - setIsAlertOpen(true); + toast.success("Service settings updated successfully"); // Send a Slack notification to #planx-notifications sendFlowStatusSlackNotification(values.status); // Reset "dirty" status to disable Save & Reset buttons @@ -487,15 +474,6 @@ const ServiceSettings: React.FC = () => { - - - Service settings updated successfully - - ); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/AddNewEditorModal.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/AddNewEditorModal.tsx index f8296ce656..2d83add36d 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/AddNewEditorModal.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/AddNewEditorModal.tsx @@ -5,6 +5,7 @@ import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import Typography from "@mui/material/Typography"; import { FormikHelpers, useFormik } from "formik"; +import { useToast } from "hooks/useToast"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useState } from "react"; import InputGroup from "ui/editor/InputGroup"; @@ -24,15 +25,14 @@ import { optimisticallyUpdateMembersTable } from "./lib/optimisticallyUpdateMemb export const AddNewEditorModal = ({ showModal, setShowModal, - setShowSuccessToast, - setShowErrorToast, }: AddNewEditorModalProps) => { const [showUserAlreadyExistsError, setShowUserAlreadyExistsError] = useState(false); + const toast = useToast(); + const clearErrors = () => { setShowUserAlreadyExistsError(false); - setShowErrorToast(false); }; const handleSubmit = async ( @@ -52,7 +52,7 @@ export const AddNewEditorModal = ({ setShowUserAlreadyExistsError(true); } if (err.message === "Unable to create user") { - setShowErrorToast(true); + toast.error("Failed to add new user, please try again"); } console.error(err); }); @@ -63,7 +63,7 @@ export const AddNewEditorModal = ({ clearErrors(); optimisticallyUpdateMembersTable(values, createUserResult.id); setShowModal(false); - setShowSuccessToast(true); + toast.success("Successfully added a user"); resetForm({ values }); }; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx index 89724f01ea..8dd3dda581 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/components/MembersTable.tsx @@ -1,6 +1,4 @@ -import Alert from "@mui/material/Alert"; import Chip from "@mui/material/Chip"; -import Snackbar from "@mui/material/Snackbar"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; @@ -19,30 +17,6 @@ export const MembersTable = ({ showAddMemberButton, }: MembersTableProps) => { const [showModal, setShowModal] = useState(false); - const [showSuccessToast, setShowSuccessToast] = useState(false); - const [showErrorToast, setShowErrorToast] = useState(false); - - const handleCloseSuccessToast = ( - _event?: React.SyntheticEvent | Event, - reason?: string, - ) => { - if (reason === "clickaway") { - return; - } - - setShowSuccessToast(false); - }; - - const handleCloseErrorToast = ( - _event?: React.SyntheticEvent | Event, - reason?: string, - ) => { - if (reason === "clickaway") { - return; - } - - setShowErrorToast(false); - }; const roleLabels: Record = { platformAdmin: "Admin", @@ -75,40 +49,9 @@ export const MembersTable = ({ )} - {showAddMemberButton && ( - - - Successfully added a user - - - )} - {showAddMemberButton && ( - - - Failed to add new user, please try again - - - )} + {showModal && ( @@ -173,44 +116,9 @@ export const MembersTable = ({ )} - {showAddMemberButton && ( - - - Successfully added a user - - - )} - {showAddMemberButton && ( - - - Failed to add new user, please try again - - - )} {showModal && ( - + )} ); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.test.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.test.tsx index b068698544..2968cd0830 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.test.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/TeamMembers.addNewEditor.test.tsx @@ -77,12 +77,7 @@ describe("when the addNewEditor modal is rendered", () => { it("should not have any accessibility issues", async () => { const { container } = setup( - {}} - showModal={true} - setShowModal={() => {}} - setShowSuccessToast={() => {}} - /> + {}} /> , ); await screen.findByTestId("modal-create-user"); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/setupTeamMembersScreen.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/setupTeamMembersScreen.tsx index 7192391db5..a5e31dfb44 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/setupTeamMembersScreen.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/tests/helpers/setupTeamMembersScreen.tsx @@ -1,4 +1,5 @@ import { screen } from "@testing-library/react"; +import { ToastContextProvider } from "contexts/ToastContext"; import React from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; @@ -9,7 +10,9 @@ import { TeamMembers } from "../../TeamMembers"; export const setupTeamMembersScreen = async () => { const setupResult = setup( - + + + , ); await screen.findByTestId("team-editors"); diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts b/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts index 2eb9dfd373..bf823c5352 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts +++ b/editor.planx.uk/src/pages/FlowEditor/components/Team/types.ts @@ -12,8 +12,6 @@ export interface MembersTableProps { export interface AddNewEditorModalProps { showModal: boolean; setShowModal: React.Dispatch>; - setShowSuccessToast: React.Dispatch>; - setShowErrorToast: React.Dispatch>; } export interface AddNewEditorFormValues { diff --git a/editor.planx.uk/src/pages/GlobalSettings.tsx b/editor.planx.uk/src/pages/GlobalSettings.tsx index 13c1f60211..38da050091 100644 --- a/editor.planx.uk/src/pages/GlobalSettings.tsx +++ b/editor.planx.uk/src/pages/GlobalSettings.tsx @@ -1,12 +1,11 @@ -import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Container from "@mui/material/Container"; -import Snackbar from "@mui/material/Snackbar"; import Typography from "@mui/material/Typography"; import { useFormik } from "formik"; +import { useToast } from "hooks/useToast"; import { useStore } from "pages/FlowEditor/lib/store"; -import React, { useState } from "react"; +import React from "react"; import type { TextContent } from "types"; import InputGroup from "ui/editor/InputGroup"; import InputLegend from "ui/editor/InputLegend"; @@ -24,8 +23,7 @@ function Component() { state.globalSettings, state.updateGlobalSettings, ]); - - const [isAlertOpen, setIsAlertOpen] = useState(false); + const toast = useToast(); const formik = useFormik({ initialValues: { @@ -49,73 +47,51 @@ function Component() { ); updateGlobalSettings(formatted); - setIsAlertOpen(true); + toast.success("Footer settings updated successfully"); }, }); - const handleClose = ( - _event?: React.SyntheticEvent | Event, - reason?: string, - ) => { - if (reason === "clickaway") { - return; - } - - setIsAlertOpen(false); - }; - return ( - <> - -
- - - Global Settings - - - - - Footer Elements - -

Manage the content that will appear in the footer.

-

- The heading will appear as a footer link which will open a - content page. -

-
- - { - formik.setFieldValue("footerContent", newOptions); - }} - newValue={() => - ({ - heading: "", - content: "", - show: true, - }) as TextContent - } - Editor={ContentEditor} - /> - -
- -
-
-
- - - Footer settings updated successfully - - - + +
+ + + Global Settings + + + + + Footer Elements + +

Manage the content that will appear in the footer.

+

+ The heading will appear as a footer link which will open a + content page. +

+
+ + { + formik.setFieldValue("footerContent", newOptions); + }} + newValue={() => + ({ + heading: "", + content: "", + show: true, + }) as TextContent + } + Editor={ContentEditor} + /> + +
+ +
+
+
); } diff --git a/editor.planx.uk/src/reducers/toastReducer.ts b/editor.planx.uk/src/reducers/toastReducer.ts new file mode 100644 index 0000000000..bf22a221ad --- /dev/null +++ b/editor.planx.uk/src/reducers/toastReducer.ts @@ -0,0 +1,24 @@ +import { ToastAction, ToastState } from "components/Toast/types"; + +export const toastReducer = (state: ToastState, action: ToastAction) => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [...state.toasts, action.payload], + }; + case "DELETE_TOAST": { + const updatedToasts = state.toasts.filter( + (toast) => toast.id !== action.payload.id, + ); + return { + ...state, + toasts: updatedToasts, + }; + } + default: + // @ts-ignore + // Typescript complains because action is of type 'never' here + throw new Error(`Unhandled action type: ${action.type}`); + } +};