diff --git a/assets/images/error-icon.svg b/assets/images/error-icon.svg new file mode 100644 index 000000000..0918ec675 --- /dev/null +++ b/assets/images/error-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/info-icon.svg b/assets/images/info-icon.svg new file mode 100644 index 000000000..425fc218d --- /dev/null +++ b/assets/images/info-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/success-icon.svg b/assets/images/success-icon.svg new file mode 100644 index 000000000..7f7965403 --- /dev/null +++ b/assets/images/success-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/warning-icon.svg b/assets/images/warning-icon.svg new file mode 100644 index 000000000..f073d2756 --- /dev/null +++ b/assets/images/warning-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index b10a8815f..c97881d0b 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1420,6 +1420,18 @@ "context": "weight", "string": "{value} {unit}" }, + "src_dot_components_dot_messages_dot_1219076963": { + "context": "snackbar expand", + "string": "Expand" + }, + "src_dot_components_dot_messages_dot_2473863536": { + "context": "snackbar button undo", + "string": "Undo" + }, + "src_dot_components_dot_messages_dot_3444275093": { + "context": "snackbar collapse", + "string": "Collapse" + }, "src_dot_configuration": { "context": "configuration section name", "string": "Configuration" diff --git a/src/components/messages/Container.tsx b/src/components/messages/Container.tsx new file mode 100644 index 000000000..c45cece2c --- /dev/null +++ b/src/components/messages/Container.tsx @@ -0,0 +1,12 @@ +import React from "react"; + +import { useStyles } from "./styles"; + +const Container = ({ children }) => { + const classes = useStyles({}); + return ( + !!children.length &&
{children}
+ ); +}; + +export default Container; diff --git a/src/components/messages/MessageManager.tsx b/src/components/messages/MessageManager.tsx index 27b4b0210..fd2953607 100644 --- a/src/components/messages/MessageManager.tsx +++ b/src/components/messages/MessageManager.tsx @@ -1,117 +1,155 @@ import Button from "@material-ui/core/Button"; import IconButton from "@material-ui/core/IconButton"; -import Snackbar from "@material-ui/core/Snackbar"; +import SnackbarContent from "@material-ui/core/SnackbarContent"; import Typography from "@material-ui/core/Typography"; import CloseIcon from "@material-ui/icons/Close"; -import React from "react"; +import classNames from "classnames"; +import React, { useState } from "react"; +import { FormattedMessage } from "react-intl"; -import { IMessage, MessageContext } from "./"; +import { INotification } from "./"; +import { useStyles } from "./styles"; -interface Message extends IMessage { - key: string; -} -interface MessageManagerState { - message: Message; - opened: boolean; +export interface IMessageManagerProps extends INotification { + onMouseEnter?: () => void; + onMouseLeave?: () => void; } -export class MessageManager extends React.Component<{}, MessageManagerState> { - state: MessageManagerState = { - message: { key: "0", onUndo: undefined, text: "" }, - opened: false - }; - queue = []; +export const MessageManagerTemplate: React.FC = props => { + const { + close, + onMouseEnter, + onMouseLeave, + message: { actionBtn, expandText, status = "info", title, text, onUndo }, + timeout + } = props; - handleClose = (_, reason) => { - if (reason === "clickaway") { - return; - } - this.setState({ opened: false }); - }; + const [expand, setExpand] = useState(false); - handleExited = () => { - this.processQueue(); - }; + const classes = useStyles({}); + const id = props.id.toString(); - pushMessage = (message: IMessage) => { - this.queue.push({ - key: new Date().getTime(), - ...message - }); - - if (this.state.opened) { - this.setState({ opened: false }); - } else { - this.processQueue(); - } - }; - - processQueue = () => { - if (this.queue.length > 0) { - this.setState({ - message: this.queue.shift(), - opened: true - }); - } - }; - - render() { - const { autohide = 3000, title, text, key, onUndo } = this.state.message; - return ( - <> - - {title && ( - - {title} - - )} + return ( +
+ + {title && ( + + {title} + + )} + {text} - - } - title={title} - action={[ - !!onUndo ? ( + + + } + action={[ + !!expandText ? ( +
+
+

{expandText}

+
+ +
+ ) : ( + undefined + ), +
+ {!!onUndo && ( - ) : ( - undefined - ), - - - - ]} - /> - - {this.props.children} - - - ); - } -} -export default MessageManager; + )} + {!!actionBtn && ( + + )} +
, + + + , +
+
+
+ ]} + /> +
+ ); +}; diff --git a/src/components/messages/MessageManagerProvider.tsx b/src/components/messages/MessageManagerProvider.tsx new file mode 100644 index 000000000..4f20b8af3 --- /dev/null +++ b/src/components/messages/MessageManagerProvider.tsx @@ -0,0 +1,130 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { TransitionGroup } from "react-transition-group"; + +import { + INotification, + ITimer, + MessageContext, + MessageManagerTemplate +} from "."; +import Container from "./Container"; +import Transition from "./Transition"; + +const MessageManagerProvider = ({ children }) => { + const root = useRef(null); + const timersArr = useRef([]); + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + root.current = document.createElement("div"); + root.current.id = "__message-manager__"; + document.body.appendChild(root.current); + const timersArrRef = timersArr.current; + + return () => { + timersArrRef.forEach(timer => clearTimeout(timer.timeoutId)); + if (root.current) { + document.body.removeChild(root.current); + } + }; + }, []); + + const timerCallback = (notification: INotification) => { + remove(notification.id); + timersArr.current = timersArr.current.filter( + timer => timer.id !== notification.id + ); + }; + + const remove = useCallback(notificationId => { + setNotifications(currentNotifications => + currentNotifications.filter(n => n.id !== notificationId) + ); + }, []); + + const show = useCallback((message = {}, timeout = 3000) => { + const id = Date.now(); + const notification = { + close: () => remove(id), + id, + message, + timeout + }; + if (timeout !== null) { + const timeoutId = window.setTimeout(() => { + timerCallback(notification); + }, timeout); + + timersArr.current.push({ + id: notification.id, + notification, + remaining: timeout, + start: new Date().getTime(), + timeoutId + }); + } + + setNotifications(state => [notification, ...state]); + + return notification; + }, []); + + const getCurrentTimer = (notification: INotification) => { + const currentTimerIndex = timersArr.current.findIndex( + timer => timer.id === notification.id + ); + return timersArr.current[currentTimerIndex]; + }; + + const pauseTimer = (notification: INotification) => { + const currentTimer = getCurrentTimer(notification); + if (currentTimer) { + currentTimer.remaining = + currentTimer.remaining - (new Date().getTime() - currentTimer.start); + window.clearTimeout(currentTimer.timeoutId); + } + }; + const resumeTimer = (notification: INotification) => { + const currentTimer = getCurrentTimer(notification); + if (currentTimer) { + currentTimer.start = new Date().getTime(); + currentTimer.timeoutId = window.setTimeout( + () => timerCallback(notification), + currentTimer.remaining + ); + } + }; + + return ( + + {children} + {root.current && + createPortal( + + {!!notifications.length && + notifications.map(notification => ( + + pauseTimer(notification), + onMouseLeave: () => resumeTimer(notification) + } + : {})} + /> + + ))} + , + root.current + )} + + ); +}; + +export default MessageManagerProvider; diff --git a/src/components/messages/Transition.tsx b/src/components/messages/Transition.tsx new file mode 100644 index 000000000..e14c12483 --- /dev/null +++ b/src/components/messages/Transition.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Transition as MessageManagerTransition } from "react-transition-group"; + +const duration = 250; + +const defaultStyle = { + opacity: 0, + transition: `opacity ${duration}ms ease` +}; + +const transitionStyles = { + entered: { opacity: 1 }, + entering: { opacity: 0 } +}; + +const Transition = ({ children, ...props }) => ( + + {state => ( +
+ {children} +
+ )} +
+); + +export default Transition; diff --git a/src/components/messages/index.ts b/src/components/messages/index.ts index 95424f09f..264a4fa35 100644 --- a/src/components/messages/index.ts +++ b/src/components/messages/index.ts @@ -1,13 +1,48 @@ import { createContext } from "react"; +export type Status = "success" | "error" | "info" | "warning"; export interface IMessage { + actionBtn?: { + label: string; + action: () => void; + }; autohide?: number; + expandText?: string; title?: string; text: string; onUndo?: () => void; + status?: Status; } + +export interface INotification { + id: number; + message: IMessage; + timeout: number; + close: () => void; +} + +export interface ITimer { + id: number; + notification: INotification; + remaining: number; + start: number; + timeoutId: number; +} + +export const types = { + ERROR: "error", + INFO: "info", + SUCCESS: "success", + WARNING: "warning" +}; +export interface INotificationContext { + show: (message: IMessage, timeout?: number | null) => void; + remove: (notification: INotification) => void; +} + export type IMessageContext = (message: IMessage) => void; -export const MessageContext = createContext(undefined); +export const MessageContext = createContext(null); export * from "./MessageManager"; -export default MessageContext.Consumer; +export * from "./MessageManagerProvider"; +export { default } from "./MessageManagerProvider"; diff --git a/src/components/messages/styles.ts b/src/components/messages/styles.ts new file mode 100644 index 000000000..d462a93b7 --- /dev/null +++ b/src/components/messages/styles.ts @@ -0,0 +1,213 @@ +import errorIcon from "@assets/images/error-icon.svg"; +import infoIcon from "@assets/images/info-icon.svg"; +import successIcon from "@assets/images/success-icon.svg"; +import warningIcon from "@assets/images/warning-icon.svg"; +import { makeStyles } from "@material-ui/core/styles"; +import { darken } from "@material-ui/core/styles/colorManipulator"; + +const successColor = "#60DAA0"; +const warningColor = "#FFB84E"; +const infoColor = "#CAD8DF"; +const errorColor = "#FE6E76"; + +export const useStyles = makeStyles( + theme => ({ + "@keyframes bar": { + from: { transform: "translateX(-100%)" }, + to: { transform: "translateX(0)" } + }, + actionContainer: { + marginLeft: -8 + }, + closeBtn: { + "& svg": { + maxHeight: 18, + maxWidth: 18 + }, + color: "#fff", + padding: 10, + position: "absolute", + right: 5, + top: 7 + }, + closeBtnInfo: { + color: theme.palette.text.primary + }, + container: { + display: "grid", + gridTemplateRows: "repeat(auto-fill, minmax(90px, 1fr))", + justifyContent: "end", + left: 0, + pointerEvents: "none", + position: "fixed", + top: 0, + width: "100%", + zIndex: 1200 + }, + error: { + "& > div": { + "& button span": { + color: "#fff" + } + }, + "& > div:first-child": { + "&:before": { + backgroundImage: `url(${errorIcon})` + } + }, + backgroundColor: errorColor, + color: "#fff" + }, + expandBtn: { + "&:before": { + borderLeft: "4px solid transparent", + borderRight: "4px solid transparent", + borderTop: "8px solid #fff", + content: "''", + display: "block", + height: 0, + position: "absolute", + right: 0, + top: "50%", + transform: "translateY(-50%)", + width: 0 + }, + background: "transparent", + border: "none", + color: "#fff", + cursor: "pointer", + fontSize: theme.spacing(2), + outline: "none", + padding: 0, + paddingRight: 15, + position: "relative" + }, + expandBtnInfo: { + "&:before": { + borderTop: `8px solid ${theme.palette.text.primary}` + }, + color: theme.palette.text.primary + }, + expandedContainer: { + "& p": { + margin: theme.spacing(1, 0) + }, + color: "#fff", + marginBottom: 5 + }, + expandedContainerContent: { + overflow: "hidden", + transition: "max-height .6s ease" + }, + expandedContainerInfo: { + color: theme.palette.text.secondary + }, + expandedText: { + maxHeight: 500 + }, + hiddenText: { + maxHeight: 0 + }, + info: { + "& > div:first-child": { + "&:before": { + backgroundImage: `url(${infoIcon})` + } + } + }, + progressBar: { + animation: `$bar var(--animationTime) ease both`, + backgroundColor: infoColor, + height: 8, + transform: "translateX(-100%)", + width: "100%" + }, + progressBarContainer: { + borderRadius: "0 0 4px 4px", + bottom: 0, + left: 0, + overflow: "hidden", + position: "absolute", + width: "calc(100%)" + }, + progressBarError: { + backgroundColor: darken(errorColor, 0.2) + }, + progressBarSuccess: { + backgroundColor: darken(successColor, 0.2) + }, + progressBarWarning: { + backgroundColor: darken(warningColor, 0.2) + }, + snackbar: { + "& > div": { + paddingLeft: 60 + }, + "& > div:first-child": { + "&:before": { + backgroundRepeat: "no-repeat", + backgroundSize: "contain", + content: "''", + display: "block", + height: 32, + left: 15, + position: "absolute", + top: 13, + width: 32 + }, + paddingTop: 16, + position: "relative" + }, + "&:hover": { + "& [class*='progressBar']": { + animationPlayState: "paused" + } + }, + borderRadius: 4, + paddingBottom: 15, + paddingLeft: 5, + paddingRight: 45, + position: "relative" + }, + snackbarContainer: { + borderRadius: 4, + display: "block", + margin: theme.spacing(2, 2, 0, 2), + maxWidth: 450, + pointerEvents: "all", + position: "relative" + }, + success: { + "& > div:first-child": { + "&:before": { + backgroundImage: `url(${successIcon})` + } + }, + "& button span": { + color: "#fff" + }, + backgroundColor: successColor, + color: "#fff" + }, + text: { + color: "#fff", + paddingTop: 5 + }, + textInfo: { + paddingTop: 5 + }, + warning: { + "& > div:first-child": { + "&:before": { + backgroundImage: `url(${warningIcon})` + } + }, + "& button span": { + color: "#fff" + }, + backgroundColor: warningColor, + color: "#fff" + } + }), + { name: "MessageManager" } +); diff --git a/src/hooks/useNotifier.ts b/src/hooks/useNotifier.ts index af927991f..b18186511 100644 --- a/src/hooks/useNotifier.ts +++ b/src/hooks/useNotifier.ts @@ -1,9 +1,16 @@ +import { IMessage } from "@saleor/components/messages"; import { IMessageContext, MessageContext } from "@saleor/components/messages"; import { useContext } from "react"; export type UseNotifierResult = IMessageContext; + function useNotifier(): UseNotifierResult { - const notify = useContext(MessageContext); + const notificationContext = useContext(MessageContext); + + const notify = (options: IMessage) => { + const timeout = options.status === "error" ? null : options.autohide; + notificationContext.show(options, timeout); + }; return notify; } export default useNotifier; diff --git a/src/index.tsx b/src/index.tsx index 2c30b3571..9cc19d811 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -28,7 +28,7 @@ import CollectionSection from "./collections"; import AppLayout from "./components/AppLayout"; import { DateProvider } from "./components/Date"; import { LocaleProvider } from "./components/Locale"; -import { MessageManager } from "./components/messages"; +import MessageManagerProvider from "./components/messages"; import { ShopProvider } from "./components/Shop"; import ThemeProvider from "./components/Theme"; import { WindowTitle } from "./components/WindowTitle"; @@ -135,7 +135,7 @@ const App: React.FC = () => { - + @@ -143,7 +143,7 @@ const App: React.FC = () => { - + diff --git a/src/storybook/Decorator.tsx b/src/storybook/Decorator.tsx index 4819962cb..66e548747 100644 --- a/src/storybook/Decorator.tsx +++ b/src/storybook/Decorator.tsx @@ -3,7 +3,7 @@ import React from "react"; import { IntlProvider } from "react-intl"; import { Provider as DateProvider } from "../components/Date/DateContext"; -import { MessageManager } from "../components/messages"; +import MessageManagerProvider from "../components/messages"; import ThemeProvider from "../components/Theme"; import { TimezoneProvider } from "../components/Timezone"; @@ -18,7 +18,7 @@ export const Decorator = storyFn => ( - +
( > {storyFn()}
-
+
diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 3e65e29ed..3a3bb39b8 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -2612,6 +2612,118 @@ exports[`Storyshots Generics / Global messages default 1`] = `
`; +exports[`Storyshots Generics / Global messages with action 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`Storyshots Generics / Global messages with error status 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`Storyshots Generics / Global messages with expandText 1`] = ` +
+
+
+ +
+
+
+`; + +exports[`Storyshots Generics / Global messages with success status 1`] = ` +
+
+
+ +
+
+
+`; + exports[`Storyshots Generics / Global messages with undo action 1`] = `
`; +exports[`Storyshots Generics / Global messages with warning status 1`] = ` +
+
+
+ +
+
+
+`; + exports[`Storyshots Generics / Link with choices default 1`] = `
= ({ undo }) => { +const props = { + text: "This is message", + title: "Title" +}; +const Story: React.FC = ({ + actionBtn, + expandText, + onUndo, + status, + title, + text +}) => { const pushMessage = useNotifier(); return ( @@ -18,8 +27,12 @@ const Story: React.FC = ({ undo }) => { variant="contained" onClick={() => pushMessage({ - onUndo: undo ? () => undefined : undefined, - text: "This is message" + actionBtn, + expandText, + onUndo: onUndo ? () => undefined : undefined, + status, + text, + title }) } style={{ display: "block", margin: "auto" }} @@ -32,5 +45,39 @@ const Story: React.FC = ({ undo }) => { storiesOf("Generics / Global messages", module) .addDecorator(CardDecorator) .addDecorator(Decorator) - .add("default", () => ) - .add("with undo action", () => ); + .add("default", () => ) + .add("with undo action", () => undefined} {...props} />) + .add("with expandText", () => ( + + )) + .add("with action", () => ( + undefined, label: "Action" }} + {...props} + /> + )) + .add("with success status", () => ( + undefined, label: "Action" }} + status="success" + title="Success!" + /> + )) + .add("with error status", () => ( + undefined, label: "Action" }} + expandText={"Some expanded text"} + status="error" + title="Error" + /> + )) + .add("with warning status", () => ( + + )); diff --git a/src/theme.ts b/src/theme.ts index 42a02f7b1..bddcd3d94 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -343,7 +343,10 @@ export default (colors: IThemeColors): Theme => color: colors.font.default } }, - alignSelf: "baseline" + display: "block", + paddingBottom: 10, + paddingLeft: 0, + paddingRight: 45 }, message: { fontSize: 16 @@ -353,8 +356,7 @@ export default (colors: IThemeColors): Theme => boxShadow: "0 6px 10px 0px rgba(0, 0, 0, 0.15), 0 1px 18px 0px rgba(0, 0, 0, 0.12), 0 3px 5px -1px rgba(0, 0, 0, 0.10)", color: colors.font.default, - display: "grid", - gridTemplateColumns: "1fr 56px", + display: "block", maxWidth: 480 } },