Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c2c8226
fix: error messages stay until closed. new error messages display in …
ako3131 Oct 13, 2026
7757802
fix: show the newest snackbar message when two arrive at once
ako3131 Oct 28, 2026
8430d87
fix: don't check for duplicates on a message that is closing
ako3131 Oct 28, 2026
4513781
fix: test name tweak and get rid of unused event
ako3131 Oct 29, 2026
df93ae1
fix: clean up long commend and insonsistent block syntax
ako3131 Nov 3, 2026
e39f609
fix: transition bug when one message is closing
ako3131 Nov 3, 2026
77a20fc
fix: add test for success message timer
ako3131 Nov 4, 2026
a947f93
fix: increase success message time
ako3131 Nov 4, 2026
9342ff7
fix: don't drop messages, add them to the queue
ako3131 Nov 4, 2026
ac65097
fix: don't allow success messages to close an error
ako3131 Nov 5, 2026
98677db
fix: change success timeout to 6s
ako3131 Nov 5, 2026
b5e71ca
fix: error messages go away on a navigate away
ako3131 Nov 5, 2026
233f742
fix: empty messages are ignored since they would block the queue
ako3131 Nov 5, 2026
b8271d4
fix: add a test for multiple errors dropping on a navigation
ako3131 Nov 5, 2026
ecffa43
fix: change message queue to a plain queue
ako3131 Nov 5, 2026
2640f19
fix: remove drop on a navigation
ako3131 Nov 5, 2026
9e22ede
fix: drop on a navigation triggered from history
ako3131 Nov 5, 2026
9a302b1
feat: erros and successes go into different queues. success on top an…
ako3131 Nov 9, 2026
2da8686
fix: test adjustments
ako3131 Nov 9, 2026
c0e86fb
fix: ignore clickaway for both errors and successes
ako3131 Nov 10, 2026
e6d89cf
fix: update comment
ako3131 Nov 10, 2026
d926872
feat: group multiple error messages
ako3131 Nov 11, 2026
6565962
fix: clear messages in redux after they are added to the snackbar
ako3131 Nov 11, 2026
655806f
fix: drop successes if it repeats a message waiting or showing
ako3131 Nov 11, 2026
7be5f32
fix: clean up tests
ako3131 Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 198 additions & 54 deletions src/components/mui/SnackbarNotification/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,102 +11,246 @@
* 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:
- useSnackbarMessage hook that returns successMessage and errorMessage methods to trigger the snackbar.
- 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];
Comment thread
ako3131 marked this conversation as resolved.
Resolved
});

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 (
<Snackbar
open={queue.open}
onClose={onClose}
slotProps={{ transition: { onExited } }}
anchorOrigin={{ vertical, horizontal: "center" }}
autoHideDuration={isSuccess ? NOTIFICATION_TIMEOUT : null}
>
<Alert
onClose={onClose}
severity={msgData.type}
variant="filled"
sx={{ width: "100%", backgroundColor: `${msgData.type}.light` }}
iconMapping={{
success: <CheckCircleIcon fontSize="inherit" />,
error: <ErrorIcon fontSize="inherit" />
}}
>
<Typography
variant="body1"
component="div"
dangerouslySetInnerHTML={{ __html: msgData.html }}
/>
{count > 1 && (
<>
<Link
component="button"
type="button"
variant="body2"
color="inherit"
underline="hover"
aria-expanded={expanded}
onClick={() => setExpanded((isOpen) => !isOpen)}
sx={{ fontWeight: "medium" }}
>
{detailsLabel}
</Link>
<Collapse in={expanded}>
<Box
component="ul"
sx={{
m: 0,
p: 0,
listStyle: "none",
maxHeight: "40vh",
overflowY: "auto"
}}
>
{newestFirst.map((m) => (
<Box
component="li"
key={`${m.receivedAt}-${m.html}`}
sx={{ mb: 1 }}
>
<Typography variant="caption" component="div">
{formatSentAt(m)}
</Typography>
<Typography
variant="body2"
component="div"
dangerouslySetInnerHTML={{ __html: m.html }}
/>
</Box>
))}
</Box>
</Collapse>
</>
)}
</Alert>
</Snackbar>
);
};

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);

@smarcet smarcet Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ako3131 Not a defect of this PR. The single-slot overwrite lives in the reducer (src/utils/reducers.js:52-53) and predates this change; the previous setMsgData(snackbarMessage) had the same limit. It only drops a message when two SET_SNACKBAR_MESSAGE dispatches land in the same React batch. Verified against this branch on React 17.0.2 and 16.14.0: two synchronous dispatches outside a batch (the request-callback path used by snackbarErrorHandler / snackbarSuccessHandler) both display; only a pair wrapped in unstable_batchedUpdates loses the first. Buffering at the Redux boundary changes the reducer contract that consumers also implement (summit-admin has its own base-reducer), so it belongs in a separate change. Leaving this open for the author to decide; I'd track it as a follow-up rather than fix it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this fix can go in a follow-up PR.

// the list owns it now, so a persisted store won't replay it on reload
clearConsumedSnackbarMessage(snackbarMessage);
}
}, [snackbarMessage]);

return (
<SnackbarNotificationContext.Provider value={messageContext}>
<Snackbar
open={open}
onClose={onClose}
slotProps={{ transition: { onExited: handleExited } }}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
autoHideDuration={NOTIFICATION_TIMEOUT}
>
<Alert
onClose={onClose}
severity={msgData.type}
variant="filled"
sx={{ width: "100%", backgroundColor: `${msgData.type}.light` }}
iconMapping={{
success: <CheckCircleIcon fontSize="inherit" />,
error: <ErrorIcon fontSize="inherit" />
}}
>
<Typography
variant="body1"
component="div"
dangerouslySetInnerHTML={{ __html: msgData.html }}
/>
</Alert>
</Snackbar>
<NotificationSnackbar queue={successes} vertical="top" />
<NotificationSnackbar queue={errors} vertical="bottom" />
{children}
</SnackbarNotificationContext.Provider>
);
};

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
);
Loading
Loading