Repository navigation
Fix/snackbar error visibility #384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c2c8226
7757802
8430d87
4513781
df93ae1
e39f609
77a20fc
a947f93
9342ff7
ac65097
98677db
b5e71ca
233f742
b8271d4
ecffa43
2640f19
9e22ede
9a302b1
2da8686
c0e86fb
e6d89cf
d926872
6565962
655806f
7be5f32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]; | ||
| }); | ||
|
|
||
| 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| ); | ||
Uh oh!
There was an error while loading. Please reload this page.