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;