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 (
- <>
-
-
-
-
-
- Footer settings updated successfully
-
-
- >
+
+
+
);
}
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}`);
+ }
+};