diff --git a/src/components/mui/SnackbarNotification/index.js b/src/components/mui/SnackbarNotification/index.js
index b30ff666..1fd0ebf3 100644
--- a/src/components/mui/SnackbarNotification/index.js
+++ b/src/components/mui/SnackbarNotification/index.js
@@ -11,17 +11,29 @@
* limitations under the License.
* */
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { connect } from "react-redux";
import PropTypes from "prop-types";
-import { Alert, Snackbar, Typography } from "@mui/material";
+import T from "i18n-react/dist/i18n-react";
+import {
+ Alert,
+ Box,
+ Collapse,
+ Link,
+ Snackbar,
+ Typography
+} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import SnackbarNotificationContext from "./Context";
export { useSnackbarMessage } from "./Context";
-import { NOTIFICATION_TIMEOUT } from "../../../utils/constants";
-import { empty } from "../../../utils/methods";
-import { clearSnackbarMessage } from "../../../utils/actions";
+import {
+ MILLISECONDS_IN_SECOND,
+ NOTIFICATION_DEDUPE_WINDOW,
+ NOTIFICATION_TIMEOUT
+} from "../../../utils/constants";
+import { empty, formatEpoch } from "../../../utils/methods";
+import { clearConsumedSnackbarMessage } from "../../../utils/actions";
/*
This component works in two ways:
@@ -29,70 +41,198 @@ import { clearSnackbarMessage } from "../../../utils/actions";
- snackbarErrorHandler and snackbarSuccessHandler actions from base-actions that change the base reducer
*/
-const SnackbarNotification = ({
- children,
- snackbarMessage,
- clearSnackbarMessage
-}) => {
- const [open, setOpen] = useState(false);
- const [msgData, setMsgData] = useState({});
- // this two methods are for on-demand messaging
- const successMessage = (msg) => setMsgData({ html: msg, type: "success" });
- const errorMessage = (msg) => setMsgData({ html: msg, type: "warning" });
- const messageContext = useMemo(() => ({ successMessage, errorMessage }), []);
+const formatSentAt = (msg) =>
+ formatEpoch(msg.receivedAt / MILLISECONDS_IN_SECOND, "h:mm:ss a");
+
+// a queue shows its first message, a group shows all of them at once
+const useMessages = ({ grouped = false } = {}) => {
+ const [messages, setMessages] = useState([]);
+ // the messages fading out stay on screen until they are gone
+ const [closing, setClosing] = useState(null);
+ const shown = closing || (grouped ? messages : messages.slice(0, 1));
+ const current = shown[shown.length - 1];
+ const open = !closing && shown.length > 0;
+ // the history listener only sees the first render, so it reads refs
+ const shownRef = useRef(shown);
+ useEffect(() => {
+ shownRef.current = shown;
+ });
+ // a copy of closing for callbacks that only see the first render
+ const closingRef = useRef([]);
+ const add = (msg) =>
+ setMessages((prev) => {
+ // a blank message never opens, since it would block the queue
+ if (empty(msg.html)) return prev;
+ const now = Date.now();
+ // skip a repeat unless the first is fading out; errors only within the dedupe window
+ const isRepeat = prev.some(
+ (m) =>
+ !closingRef.current.includes(m) &&
+ m.html === msg.html &&
+ m.type === msg.type &&
+ (!grouped || now - m.receivedAt < NOTIFICATION_DEDUPE_WINDOW)
+ );
+ if (isRepeat) return prev;
+ const next = { ...msg, receivedAt: now };
+ return [...prev, next];
+ });
- const clearMessage = () => {
- clearSnackbarMessage();
- setMsgData({});
+ // closes what is on screen; anything newer shows once it has faded out
+ const close = () => {
+ if (!shownRef.current.length) return;
+ closingRef.current = shownRef.current;
+ setClosing(shownRef.current);
};
- const onClose = () => {
- setOpen(false);
+ const onExited = () => {
+ const closed = closingRef.current;
+ closingRef.current = [];
+ setMessages((prev) => prev.filter((m) => !closed.includes(m)));
+ setClosing(null);
};
- const handleExited = () => {
- clearMessage();
+ return { open, current, shown, add, close, onExited };
+};
+
+const NotificationSnackbar = ({ queue, vertical }) => {
+ const msgData = queue.current || {};
+ const isSuccess = msgData.type === "success";
+ const count = queue.shown.length;
+ const newestFirst = [...queue.shown].reverse();
+ const [expanded, setExpanded] = useState(false);
+ const detailsLabel = T.translate(
+ expanded
+ ? "snackbar_notification.hide_details"
+ : "snackbar_notification.view_details",
+ { count }
+ );
+
+ const onClose = (event, reason) => {
+ // messages ignore clickaways
+ if (reason === "clickaway") return;
+ // one Escape press closes one snackbar
+ if (reason === "escapeKeyDown") event.preventDefault();
+ queue.close();
};
+ const onExited = () => {
+ setExpanded(false);
+ queue.onExited();
+ };
+
+ return (
+
+ ,
+ error:
+ }}
+ >
+
+ {count > 1 && (
+ <>
+ setExpanded((isOpen) => !isOpen)}
+ sx={{ fontWeight: "medium" }}
+ >
+ {detailsLabel}
+
+
+
+ {newestFirst.map((m) => (
+
+
+ {formatSentAt(m)}
+
+
+
+ ))}
+
+
+ >
+ )}
+
+
+ );
+};
+
+const SnackbarNotification = ({
+ children,
+ snackbarMessage,
+ clearConsumedSnackbarMessage,
+ history
+}) => {
+ // separate lists, so a success never waits behind an error
+ const errors = useMessages({ grouped: true });
+ const successes = useMessages();
+ const addMessage = (msg) =>
+ (msg.type === "success" ? successes : errors).add(msg);
+ // this two methods are for on-demand messaging
+ const successMessage = (msg) => addMessage({ html: msg, type: "success" });
+ const errorMessage = (msg) => addMessage({ html: msg, type: "warning" });
+ const messageContext = useMemo(() => ({ successMessage, errorMessage }), []);
+
+ // leaving a page closes its errors, successes keep their timer
useEffect(() => {
- if (!empty(msgData.html)) {
- setOpen(true);
- }
- }, [msgData]);
+ if (!history) return;
+ let path = history.location.pathname;
+ return history.listen(() => {
+ if (history.location.pathname === path) return;
+ path = history.location.pathname;
+ errors.close();
+ });
+ }, [history]);
// when snackbarMessage changes in base-reducer, we trigger the snackbar
useEffect(() => {
if (!empty(snackbarMessage?.html)) {
- setMsgData(snackbarMessage);
+ addMessage(snackbarMessage);
+ // the list owns it now, so a persisted store won't replay it on reload
+ clearConsumedSnackbarMessage(snackbarMessage);
}
}, [snackbarMessage]);
return (
-
- ,
- error:
- }}
- >
-
-
-
+
+
{children}
);
@@ -100,13 +240,17 @@ const SnackbarNotification = ({
SnackbarNotification.propTypes = {
children: PropTypes.node,
- snackbarMessage: PropTypes.object
+ snackbarMessage: PropTypes.object,
+ history: PropTypes.shape({
+ listen: PropTypes.func,
+ location: PropTypes.object
+ })
};
const mapStateToProps = ({ baseState }) => ({
snackbarMessage: baseState.snackbarMessage
});
-export default connect(mapStateToProps, { clearSnackbarMessage })(
+export default connect(mapStateToProps, { clearConsumedSnackbarMessage })(
SnackbarNotification
);
diff --git a/src/components/mui/__tests__/snackbar-notification.test.js b/src/components/mui/__tests__/snackbar-notification.test.js
index b14a52f5..8b5d050b 100644
--- a/src/components/mui/__tests__/snackbar-notification.test.js
+++ b/src/components/mui/__tests__/snackbar-notification.test.js
@@ -16,18 +16,25 @@ jest.mock("react-redux", () => ({
}));
jest.mock("../../../utils/actions", () => ({
- clearSnackbarMessage: jest.fn()
+ clearConsumedSnackbarMessage: jest.fn()
}));
import React from "react";
-import { render, screen } from "@testing-library/react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
-import SnackbarNotification from "../SnackbarNotification/index";
+import { createMemoryHistory } from "history";
+import SnackbarNotification, {
+ useSnackbarMessage
+} from "../SnackbarNotification/index";
+import {
+ NOTIFICATION_DEDUPE_WINDOW,
+ NOTIFICATION_TIMEOUT
+} from "../../../utils/constants";
describe("SnackbarNotification", () => {
test("renders children", () => {
render(
-
+
Child content
);
@@ -38,7 +45,7 @@ describe("SnackbarNotification", () => {
render(
App
@@ -50,7 +57,7 @@ describe("SnackbarNotification", () => {
render(
App
@@ -62,7 +69,7 @@ describe("SnackbarNotification", () => {
render(
App
@@ -70,4 +77,316 @@ describe("SnackbarNotification", () => {
// The snackbar should not be open
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
+
+ describe("pinned and grouped errors", () => {
+ const errorA = { html: "First error", type: "warning" };
+ const errorB = { html: "Second error", type: "error" };
+ let clearConsumedSnackbarMessage;
+
+ const ui = (snackbarMessage) => (
+
+ App
+
+ );
+
+ const advance = (ms) =>
+ act(() => {
+ jest.advanceTimersByTime(ms);
+ });
+
+ const close = () =>
+ fireEvent.click(screen.getByRole("button", { name: /close/i }));
+
+ const openDetails = () =>
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "snackbar_notification.view_details"
+ })
+ );
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ clearConsumedSnackbarMessage = jest.fn();
+ });
+ afterEach(() => jest.useRealTimers());
+
+ test("keeps an error open after the auto-hide timeout", () => {
+ render(ui(errorA));
+ advance(NOTIFICATION_TIMEOUT);
+ advance(1000);
+ expect(screen.getByText("First error")).toBeInTheDocument();
+ });
+
+ test("still auto-hides a success message", () => {
+ render(ui({ html: "Saved", type: "success" }));
+ advance(NOTIFICATION_TIMEOUT);
+ advance(1000);
+ expect(screen.queryByText("Saved")).not.toBeInTheDocument();
+ });
+
+ test("does not close an error on a click elsewhere", () => {
+ render(ui(errorA));
+ advance(0);
+ fireEvent.click(screen.getByText("App"));
+ advance(500);
+ expect(screen.getByText("First error")).toBeInTheDocument();
+ });
+
+ test("closes every error in the group at once", () => {
+ const { rerender } = render(ui(errorA));
+ rerender(ui(errorB));
+ advance(1000);
+
+ close();
+ advance(1000);
+ expect(screen.queryByText("First error")).not.toBeInTheDocument();
+ expect(screen.queryByText("Second error")).not.toBeInTheDocument();
+ });
+
+ test("clears the redux message as soon as it is read", () => {
+ render(ui(errorA));
+ expect(clearConsumedSnackbarMessage).toHaveBeenCalledWith(errorA);
+ expect(screen.getByText("First error")).toBeInTheDocument();
+ });
+
+ test("shows an error that arrives while the group fades out", () => {
+ const { rerender } = render(ui(errorA));
+ close();
+ rerender(ui(errorB));
+ // the fading snackbar keeps its own text
+ expect(screen.queryByText("Second error")).not.toBeInTheDocument();
+ advance(1000);
+ expect(screen.queryByText("First error")).not.toBeInTheDocument();
+ expect(screen.getByText("Second error")).toBeInTheDocument();
+ });
+
+ test("closes one snackbar per Escape press", () => {
+ const { rerender } = render(ui(errorA));
+ rerender(ui({ html: "Saved", type: "success" }));
+ advance(0);
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ advance(1000);
+ expect(screen.getAllByRole("alert")).toHaveLength(1);
+ });
+
+ test("shows a success next to an error without waiting", () => {
+ const { rerender } = render(ui(errorA));
+ rerender(ui({ html: "Saved", type: "success" }));
+ advance(1000);
+ expect(screen.getByText("First error")).toBeInTheDocument();
+ expect(screen.getByText("Saved")).toBeInTheDocument();
+
+ advance(NOTIFICATION_TIMEOUT);
+ advance(1000);
+ expect(screen.getByText("First error")).toBeInTheDocument();
+ expect(screen.queryByText("Saved")).not.toBeInTheDocument();
+
+ // closing the error doesn't bring back the old success
+ close();
+ advance(1000);
+ expect(screen.queryByText("Saved")).not.toBeInTheDocument();
+ });
+
+ test("gives a waiting success its own full timeout", () => {
+ const { rerender } = render(ui({ html: "Saved", type: "success" }));
+ advance(NOTIFICATION_TIMEOUT - 100);
+ rerender(ui({ html: "Saved again", type: "success" }));
+ // one tick for the auto-hide, one for the exit transition
+ advance(1000);
+ advance(1000);
+ expect(screen.queryByText("Saved")).not.toBeInTheDocument();
+ advance(NOTIFICATION_TIMEOUT / 2);
+ expect(screen.getByText("Saved again")).toBeInTheDocument();
+
+ advance(NOTIFICATION_TIMEOUT);
+ advance(1000);
+ expect(screen.queryByText("Saved again")).not.toBeInTheDocument();
+ });
+
+ test("ignores a repeat success while the first is still on screen", () => {
+ const saved = { html: "Saved", type: "success" };
+ const { rerender } = render(ui(saved));
+ advance(2000);
+ rerender(ui({ ...saved }));
+ // one tick for the auto-hide, one for the exit transition
+ advance(NOTIFICATION_TIMEOUT - 2000);
+ advance(1000);
+ expect(screen.queryByText("Saved")).not.toBeInTheDocument();
+ });
+
+ test("groups two errors from the same render, newest first", () => {
+ const TwoErrors = () => {
+ const { errorMessage } = useSnackbarMessage();
+ return (
+
+ );
+ };
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("send"));
+ openDetails();
+ const items = screen.getAllByRole("listitem");
+ expect(items).toHaveLength(2);
+ expect(items[0]).toHaveTextContent("Second error");
+ expect(items[1]).toHaveTextContent("First error");
+ });
+
+ test("ignores a repeat within the dedupe window", () => {
+ const { rerender } = render(ui(errorA));
+ rerender(ui({ ...errorA }));
+ advance(1000);
+
+ close();
+ advance(1000);
+ expect(screen.queryByText("First error")).not.toBeInTheDocument();
+ });
+
+ test("shows a repeat that arrives after the error is closed", () => {
+ const { rerender } = render(ui(errorA));
+ close();
+ rerender(ui({ ...errorA }));
+ advance(1000);
+
+ expect(screen.getByText("First error")).toBeInTheDocument();
+ });
+
+ test("groups a repeat that arrives after the dedupe window", () => {
+ const { rerender } = render(ui(errorA));
+ advance(NOTIFICATION_DEDUPE_WINDOW);
+ rerender(ui({ ...errorA }));
+ advance(1000);
+
+ openDetails();
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
+ });
+
+ test("ignores a blank message so the next one still shows", () => {
+ const BlankThenError = () => {
+ const { errorMessage } = useSnackbarMessage();
+ return (
+
+ );
+ };
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText("send"));
+ expect(screen.getByText("First error")).toBeInTheDocument();
+ });
+
+ describe("on a page change", () => {
+ let history;
+
+ const page = (snackbarMessage, children = App
) => (
+
+ {children}
+
+ );
+
+ const changePage = (path) =>
+ act(() => {
+ history.push(path);
+ });
+
+ beforeEach(() => {
+ history = createMemoryHistory();
+ });
+
+ test("closes an error", () => {
+ render(page(errorA));
+ changePage("/other");
+ advance(1000);
+ expect(screen.queryByText("First error")).not.toBeInTheDocument();
+ });
+
+ test("keeps a success", () => {
+ render(page({ html: "Saved", type: "success" }));
+ changePage("/other");
+ advance(1000);
+ expect(screen.getByText("Saved")).toBeInTheDocument();
+ });
+
+ test("closes every error in the group", () => {
+ const { rerender } = render(page(errorA));
+ rerender(page(errorB));
+ changePage("/other");
+ advance(1000);
+ expect(screen.queryByText("First error")).not.toBeInTheDocument();
+ expect(screen.queryByText("Second error")).not.toBeInTheDocument();
+ });
+
+ test("shows an error after leaving a page with none on screen", () => {
+ const { rerender } = render(page(null));
+ changePage("/other");
+ rerender(page(errorA));
+ advance(1000);
+ expect(screen.getByText("First error")).toBeInTheDocument();
+ });
+
+ test("keeps an error that arrives while the group fades out", () => {
+ const { rerender } = render(page(errorA));
+ close();
+ rerender(page(errorB));
+ changePage("/other");
+ advance(1000);
+ expect(screen.getByText("Second error")).toBeInTheDocument();
+ });
+
+ test("keeps an error the new page adds on mount", () => {
+ const NewPage = () => {
+ const { errorMessage } = useSnackbarMessage();
+ React.useEffect(() => {
+ errorMessage("Page error");
+ }, []);
+ return null;
+ };
+ const { rerender } = render(page(errorA));
+ act(() => {
+ history.push("/other");
+ rerender(page(errorA, ));
+ });
+ // one for the old error to exit, one for the new one to open
+ advance(1000);
+ advance(1000);
+ expect(screen.getByText("Page error")).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 3e18da9e..88ebce24 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -65,6 +65,10 @@
"confirm_delete_title": "Confirm Deletion",
"confirm_delete": "Are you sure you want to delete this item? This action cannot be undone."
},
+ "snackbar_notification": {
+ "view_details": "{count} errors · View details",
+ "hide_details": "{count} errors · Hide details"
+ },
"mui_table": {
"no_items": "No items found.",
"no_data": "No data found.",
diff --git a/src/utils/__tests__/actions.test.js b/src/utils/__tests__/actions.test.js
index f1e3b8ab..2482a485 100644
--- a/src/utils/__tests__/actions.test.js
+++ b/src/utils/__tests__/actions.test.js
@@ -1,4 +1,4 @@
-import {authErrorHandler, snackbarErrorHandler, SET_SNACKBAR_MESSAGE, VALIDATE} from "../actions";
+import {authErrorHandler, snackbarErrorHandler, clearConsumedSnackbarMessage, CLEAR_SNACKBAR_MESSAGE, SET_SNACKBAR_MESSAGE, VALIDATE} from "../actions";
import {setAuthHandlers} from "../../components/security/methods";
import { CLEAR_SESSION_STATE } from "../../components/security/actions";
import configureMockStore from 'redux-mock-store';
@@ -209,4 +209,20 @@ describe('Utils Actions', () => {
expect(types).toContain(CLEAR_SESSION_STATE);
});
});
+
+ describe('clearConsumedSnackbarMessage', () => {
+ const msg = { html: 'Saved', type: 'success' };
+
+ test('clears the slot when it still holds the message', () => {
+ const slotStore = mockStore({ baseState: { snackbarMessage: msg } });
+ slotStore.dispatch(clearConsumedSnackbarMessage(msg));
+ expect(slotStore.getActions()).toEqual([{ type: CLEAR_SNACKBAR_MESSAGE, payload: {} }]);
+ });
+
+ test('keeps a newer message in the slot', () => {
+ const slotStore = mockStore({ baseState: { snackbarMessage: { ...msg } } });
+ slotStore.dispatch(clearConsumedSnackbarMessage(msg));
+ expect(slotStore.getActions()).toEqual([]);
+ });
+ });
});
diff --git a/src/utils/actions.js b/src/utils/actions.js
index 085639e2..afe89cf9 100644
--- a/src/utils/actions.js
+++ b/src/utils/actions.js
@@ -46,6 +46,12 @@ export const clearSnackbarMessage = () => (dispatch) => {
dispatch(createAction(CLEAR_SNACKBAR_MESSAGE)({}));
};
+// only clears if no newer message has replaced it
+export const clearConsumedSnackbarMessage = (msg) => (dispatch, getState) => {
+ if (getState().baseState.snackbarMessage === msg)
+ dispatch(createAction(CLEAR_SNACKBAR_MESSAGE)({}));
+};
+
export const setSnackbarMessage = (message) => (dispatch) => {
dispatch(createAction(SET_SNACKBAR_MESSAGE)(message));
};
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 56fc75c7..050d30df 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -10,7 +10,8 @@ export const CODE_200 = 200;
export const DEBOUNCE_WAIT_250 = 250;
export const DEBOUNCE_WAIT = 500;
-export const NOTIFICATION_TIMEOUT = 2000;
+export const NOTIFICATION_TIMEOUT = 6000;
+export const NOTIFICATION_DEDUPE_WINDOW = 1000;
export const ASYNC_SELECT_SAFETY_TIMEOUT = 15000;
export const DEFAULT_PER_PAGE = 10;
export const TWENTY_PER_PAGE = 20;