fix: Improve toast messages to not show multiple of the same

This commit is contained in:
Tom Moor
2021-01-02 21:09:43 -08:00
parent 68bbd9fa34
commit bb81aa0065
5 changed files with 79 additions and 61 deletions

View File

@ -1,30 +1,24 @@
// @flow // @flow
import { observer, inject } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import UiStore from "../../stores/UiStore";
import Toast from "./components/Toast"; import Toast from "./components/Toast";
import useStores from "hooks/useStores";
type Props = { function Toasts() {
ui: UiStore, const { ui } = useStores();
};
@observer
class Toasts extends React.Component<Props> {
render() {
const { ui } = this.props;
return ( return (
<List> <List>
{ui.orderedToasts.map((toast) => ( {ui.orderedToasts.map((toast) => (
<Toast <Toast
key={toast.id} key={toast.id}
toast={toast} toast={toast}
onRequestClose={() => ui.removeToast(toast.id)} onRequestClose={() => ui.removeToast(toast.id)}
/> />
))} ))}
</List> </List>
); );
}
} }
const List = styled.ol` const List = styled.ol`
@ -37,4 +31,4 @@ const List = styled.ol`
z-index: ${(props) => props.theme.depths.toasts}; z-index: ${(props) => props.theme.depths.toasts};
`; `;
export default inject("ui")(Toasts); export default observer(Toasts);

View File

@ -1,8 +1,8 @@
// @flow // @flow
import { darken } from "polished"; import { darken } from "polished";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled, { css } from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations"; import { fadeAndScaleIn, pulse } from "shared/styles/animations";
import type { Toast as TToast } from "types"; import type { Toast as TToast } from "types";
type Props = { type Props = {
@ -11,48 +11,46 @@ type Props = {
toast: TToast, toast: TToast,
}; };
class Toast extends React.Component<Props> { function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
timeout: TimeoutID; const timeout = React.useRef();
const [pulse, setPulse] = React.useState(false);
const { action, reoccurring } = toast;
static defaultProps = { React.useEffect(() => {
closeAfterMs: 3000, timeout.current = setTimeout(onRequestClose, toast.timeout || closeAfterMs);
};
componentDidMount() { return () => clearTimeout(timeout.current);
this.timeout = setTimeout( }, [onRequestClose, toast, closeAfterMs]);
this.props.onRequestClose,
this.props.toast.timeout || this.props.closeAfterMs
);
}
componentWillUnmount() { React.useEffect(() => {
clearTimeout(this.timeout); if (reoccurring) {
} setPulse(reoccurring);
render() { // must match animation time in css below vvv
const { toast, onRequestClose } = this.props; setTimeout(() => setPulse(false), 250);
const { action } = toast; }
const message = }, [reoccurring]);
typeof toast.message === "string"
? toast.message
: toast.message.toString();
return ( const message =
<li> typeof toast.message === "string"
<Container ? toast.message
onClick={action ? undefined : onRequestClose} : toast.message.toString();
type={toast.type || "success"}
> return (
<Message>{message}</Message> <ListItem $pulse={pulse}>
{action && ( <Container
<Action type={toast.type || "success"} onClick={action.onClick}> onClick={action ? undefined : onRequestClose}
{action.text} type={toast.type || "success"}
</Action> >
)} <Message>{message}</Message>
</Container> {action && (
</li> <Action type={toast.type || "success"} onClick={action.onClick}>
); {action.text}
} </Action>
)}
</Container>
</ListItem>
);
} }
const Action = styled.span` const Action = styled.span`
@ -71,6 +69,14 @@ const Action = styled.span`
} }
`; `;
const ListItem = styled.li`
${(props) =>
props.$pulse &&
css`
animation: ${pulse} 250ms;
`}
`;
const Container = styled.div` const Container = styled.div`
display: inline-block; display: inline-block;
align-items: center; align-items: center;

View File

@ -25,6 +25,7 @@ class UiStore {
@observable mobileSidebarVisible: boolean = false; @observable mobileSidebarVisible: boolean = false;
@observable sidebarCollapsed: boolean = false; @observable sidebarCollapsed: boolean = false;
@observable toasts: Map<string, Toast> = new Map(); @observable toasts: Map<string, Toast> = new Map();
lastToastId: string;
constructor() { constructor() {
// Rehydrate // Rehydrate
@ -178,9 +179,19 @@ class UiStore {
) => { ) => {
if (!message) return; if (!message) return;
const lastToast = this.toasts.get(this.lastToastId);
if (lastToast && lastToast.message === message) {
this.toasts.set(this.lastToastId, {
...lastToast,
reoccurring: lastToast.reoccurring ? ++lastToast.reoccurring : 1,
});
return;
}
const id = v4(); const id = v4();
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
this.toasts.set(id, { message, createdAt, id, ...options }); this.toasts.set(id, { message, createdAt, id, ...options });
this.lastToastId = id;
return id; return id;
}; };

View File

@ -17,6 +17,7 @@ export type Toast = {
message: string, message: string,
type: "warning" | "error" | "info" | "success", type: "warning" | "error" | "info" | "success",
timeout?: number, timeout?: number,
reoccurring?: number,
action?: { action?: {
text: string, text: string,
onClick: () => void, onClick: () => void,

View File

@ -80,3 +80,9 @@ export const pulsate = keyframes`
50% { opacity: 0.5; } 50% { opacity: 0.5; }
100% { opacity: 1; } 100% { opacity: 1; }
`; `;
export const pulse = keyframes`
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
`;