diff --git a/.storybook/main.js b/.storybook/main.js index 24ecb83bad..62f27efebc 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -5,4 +5,16 @@ module.exports = { '@storybook/addon-storysource', '@storybook/addon-knobs', ], + webpackFinal: async (config, { configType }) => { + // Resolve error when webpack-ing storybook: + // Can't import the named export 'Children' from non EcmaScript module (only + // default export is available) + config.module.rules.push({ + test: /\.mjs$/, + include: /node_modules/, + type: 'javascript/auto', + }); + + return config; + }, }; diff --git a/package-lock.json b/package-lock.json index 71456c9f38..cd45c17be2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.98.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@floating-ui/dom": "^0.1.10" + "@floating-ui/dom": "^0.1.10", + "framer-motion": "^4.1.17" }, "devDependencies": { "@babel/cli": "^7.17.10", @@ -34,6 +35,7 @@ "@storybook/theming": "^6.4.22", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^11.2.7", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.1.9", "@types/jest": "^27.5.0", "@types/react": "^18.0.8", @@ -2262,7 +2264,7 @@ "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "dev": true, + "devOptional": true, "dependencies": { "@emotion/memoize": "0.7.4" } @@ -2271,7 +2273,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "dev": true + "devOptional": true }, "node_modules/@emotion/react": { "version": "11.9.0", @@ -6260,6 +6262,36 @@ "react-dom": "*" } }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "13.5.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", @@ -14056,6 +14088,33 @@ "node": ">=0.10.0" } }, + "node_modules/framer-motion": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-4.1.17.tgz", + "integrity": "sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw==", + "dependencies": { + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "popmotion": "9.3.6", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": ">=16.8 || ^17.0.0", + "react-dom": ">=16.8 || ^17.0.0" + } + }, + "node_modules/framesync": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-5.3.0.tgz", + "integrity": "sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -14962,6 +15021,11 @@ "lower-case": "^1.1.1" } }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -20812,6 +20876,17 @@ "node": ">=10" } }, + "node_modules/popmotion": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.3.6.tgz", + "integrity": "sha512-ZTbXiu6zIggXzIliMi8LGxXBF5ST+wkpXGEjeTUDUOCdSQ356hij/xjeUdv0F8zCQNeqB1+PR5/BB+gC+QLAPw==", + "dependencies": { + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + } + }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -21570,6 +21645,22 @@ "node": ">=0.10.0" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-fast-compare": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", @@ -24324,6 +24415,15 @@ "inline-style-parser": "0.1.1" } }, + "node_modules/style-value-types": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-4.1.4.tgz", + "integrity": "sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==", + "dependencies": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, "node_modules/styled-components": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz", @@ -25194,8 +25294,7 @@ "node_modules/tslib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -29262,7 +29361,7 @@ "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", - "dev": true, + "devOptional": true, "requires": { "@emotion/memoize": "0.7.4" } @@ -29271,7 +29370,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "dev": true + "devOptional": true }, "@emotion/react": { "version": "11.9.0", @@ -32143,6 +32242,16 @@ "@testing-library/dom": "^7.28.1" } }, + "@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + } + }, "@testing-library/user-event": { "version": "13.5.0", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", @@ -38420,6 +38529,27 @@ "map-cache": "^0.2.2" } }, + "framer-motion": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-4.1.17.tgz", + "integrity": "sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw==", + "requires": { + "@emotion/is-prop-valid": "^0.8.2", + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "popmotion": "9.3.6", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + } + }, + "framesync": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-5.3.0.tgz", + "integrity": "sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==", + "requires": { + "tslib": "^2.1.0" + } + }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -39132,6 +39262,11 @@ } } }, + "hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + }, "highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -43597,6 +43732,17 @@ "@babel/runtime": "^7.12.5" } }, + "popmotion": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.3.6.tgz", + "integrity": "sha512-ZTbXiu6zIggXzIliMi8LGxXBF5ST+wkpXGEjeTUDUOCdSQ356hij/xjeUdv0F8zCQNeqB1+PR5/BB+gC+QLAPw==", + "requires": { + "framesync": "5.3.0", + "hey-listen": "^1.0.8", + "style-value-types": "4.1.4", + "tslib": "^2.1.0" + } + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -44175,6 +44321,15 @@ } } }, + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-fast-compare": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", @@ -46419,6 +46574,15 @@ "inline-style-parser": "0.1.1" } }, + "style-value-types": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-4.1.4.tgz", + "integrity": "sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==", + "requires": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, "styled-components": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz", @@ -47127,8 +47291,7 @@ "tslib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" }, "tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index e1c11e3ffc..66579f6cd0 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@storybook/theming": "^6.4.22", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^11.2.7", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.1.9", "@types/jest": "^27.5.0", "@types/react": "^18.0.8", @@ -95,12 +96,12 @@ "vega-tooltip": "^0.27.0" }, "peerDependencies": { - "@js-temporal/polyfill": "^0.4.3", "@fortawesome/fontawesome-free": "^5.10.2", "@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/free-regular-svg-icons": "^5.15.3", "@fortawesome/free-solid-svg-icons": "^5.15.3", "@fortawesome/react-fontawesome": "^0.1.14", + "@js-temporal/polyfill": "^0.4.3", "polished": "3.4.1", "pretty-bytes": "^5.6.0", "react": "^17.0.2", @@ -137,6 +138,7 @@ } }, "dependencies": { - "@floating-ui/dom": "^0.1.10" + "@floating-ui/dom": "^0.1.10", + "framer-motion": "^4.1.17" } } diff --git a/src/lib/components/progressbar/ProgressBar.component.tsx b/src/lib/components/progressbar/ProgressBar.component.tsx index 6da6294fcd..a3e5dee982 100644 --- a/src/lib/components/progressbar/ProgressBar.component.tsx +++ b/src/lib/components/progressbar/ProgressBar.component.tsx @@ -4,7 +4,7 @@ import * as defaultTheme from '../../style/theme'; import { Size } from '../constants'; export type ProgressBarProps = { percentage: number; - size?: Size; + size?: Size | 'custom'; color?: string; // The color of unfill bar backgroundColor?: string; @@ -15,12 +15,14 @@ export type ProgressBarProps = { buildinLabel?: string; // The animation to full the progress bar isAnimation?: boolean; + height?: React.CSSProperties['height']; }; const Container = styled.div``; const ProgressBarContainer = styled.div<{ backgroundColor: string; - size: keyof typeof defaultTheme.fontSize; + size: keyof typeof defaultTheme.fontSize | 'custom'; buildinLabel?: string; + height?: React.CSSProperties['height']; }>` display: flex; border-radius: 4px; @@ -52,6 +54,11 @@ const ProgressBarContainer = styled.div<{ height: 20px; `; + case 'custom': + return css` + height: ${props.height}; + font-size: ${props.height}; + `; default: return css` height: ${defaultTheme.fontSize.base}; @@ -148,6 +155,7 @@ function ProgressBar({ bottomRightLabel, buildinLabel, isAnimation = false, + height, ...rest }: ProgressBarProps) { return ( @@ -171,6 +179,7 @@ function ProgressBar({ size={size} buildinLabel={buildinLabel} backgroundColor={backgroundColor} + height={height} > void; + status?: ToastStatus; + position?: ToastPosition; + autoDismiss?: boolean; + duration?: number; + icon?: React.ReactNode; + width?: React.CSSProperties['width']; + withProgressBar?: boolean; + style?: React.CSSProperties; +}; + +export const useGetBackgroundColor = (status: string) => { + const theme = useTheme(); + switch (status) { + case 'success': + return theme.statusHealthy; + case 'error': + return theme.statusCritical; + case 'warning': + return theme.statusWarning; + default: + return theme.infoPrimary; + } +}; + +const useGetRgbBackgroundColor = (status: string) => { + const theme = useTheme(); + switch (status) { + case 'success': + return `rgba(${theme.statusHealthyRGB}, 0.4)`; + case 'error': + return `rgba(${theme.statusCriticalRGB}, 0.4)`; + case 'warning': + return `rgba(${theme.statusWarningRGB}, 0.4)`; + default: + return theme.infoSecondary; + } +}; + +const defaultIconName = (status: string) => { + switch (status) { + case 'success': + return 'Check-circle'; + case 'error': + return 'Times-circle'; + case 'warning': + return 'Exclamation-circle'; + default: + return 'Info-circle'; + } +}; + +const DefaultIcon = ({ status }: { status: string }) => { + const color = useGetBackgroundColor(status); + const iconName = defaultIconName(status); + + return ; +}; + +const DEFAULT_WIDTH = '25rem'; + +const IconContainer = styled.div<{ bgColor: string }>` + align-items: center; + align-self: stretch; + border-radius: 4px 0px 0px 4px; + display: flex; + gap: 16px; + justify-content: center; + position: relative; + width: 32px; + background-color: ${(props) => props.bgColor}; +`; + +const ContentContainer = styled.div` + align-items: center; + align-self: stretch; + display: flex; + flex: 1; + flex-grow: 1; + gap: 8px; + padding: 0px 16px; + position: relative; +`; + +function Toast({ + open, + message, + onClose, + position = 'top-right', + status = 'info', + autoDismiss = true, + duration = 5000, + icon = , + width = DEFAULT_WIDTH, + withProgressBar = false, + style, +}: ToastProps) { + const ref = useRef(null); + const { params } = useToastParameters({ + open, + duration: autoDismiss ? duration : null, + onClose, + }); + + const positionStyle = positionOutput[position]; + + const bgColor = useGetBackgroundColor(status); + const rgbBgColor = useGetRgbBackgroundColor(status); + const theme = useTheme(); + + if (!open) { + return null; + } + + return ( +
+ + {icon} + + {message} + + +
+ ); +} + +export { Toast }; diff --git a/src/lib/components/toast/ToastPositionHelpers.ts b/src/lib/components/toast/ToastPositionHelpers.ts new file mode 100644 index 0000000000..5e1808c1b9 --- /dev/null +++ b/src/lib/components/toast/ToastPositionHelpers.ts @@ -0,0 +1,38 @@ +import { CSSProperties } from 'react'; + +export type ToastPosition = + | 'top-left' + | 'top-right' + | 'top-center' + | 'bottom-left' + | 'bottom-right' + | 'bottom-center'; + +export const positionOutput: Record = { + 'top-left': { + top: '3rem', + left: '1rem', + }, + 'top-right': { + top: '3rem', + right: '1rem', + }, + 'top-center': { + top: '1rem', + left: '50%', + transform: 'translateX(-50%)', + }, + 'bottom-left': { + bottom: '1rem', + left: '1rem', + }, + 'bottom-right': { + bottom: '1rem', + right: '1rem', + }, + 'bottom-center': { + bottom: '1rem', + left: '50%', + transform: 'translateX(-50%)', + }, +}; diff --git a/src/lib/components/toast/ToastProgressBar.tsx b/src/lib/components/toast/ToastProgressBar.tsx new file mode 100644 index 0000000000..35f2776557 --- /dev/null +++ b/src/lib/components/toast/ToastProgressBar.tsx @@ -0,0 +1,45 @@ +import { darken } from 'polished'; +import { useEffect, useState } from 'react'; +import { ProgressBar } from '../progressbar/ProgressBar.component'; + +export function DurationBasedProgressBar({ + duration, + color, +}: { + duration: number | null; + color: string; +}) { + const [progress, setProgress] = useState(0); + + useEffect(() => { + if (duration) { + const interval = setInterval(() => { + setProgress((prevProgress) => prevProgress + (100 / duration) * 1000); + }, 1000); + + return () => { + clearInterval(interval); + }; + } + }, [duration]); + + return ( +
+ +
+ ); +} diff --git a/src/lib/components/toast/ToastProvider.tsx b/src/lib/components/toast/ToastProvider.tsx new file mode 100644 index 0000000000..661a4514f4 --- /dev/null +++ b/src/lib/components/toast/ToastProvider.tsx @@ -0,0 +1,40 @@ +import { ReactNode, createContext, useContext, useState } from 'react'; +import { Toast, ToastProps } from './Toast.component'; + +type ToastContextState = Omit; + +export interface ToastContextType { + showToast: (toastProps: ToastContextState) => void; +} + +export const ToastContext = createContext( + undefined, +); + +interface ToastProviderProps { + children: ReactNode; +} +export const ToastProvider: React.FC = ({ children }) => { + const [toastProps, setToastProps] = useState(null); + + const showToast = (toastProps: ToastContextState) => { + setToastProps(toastProps); + }; + + return ( + + {children} + {toastProps && ( + setToastProps(null)} /> + )} + + ); +}; + +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +}; diff --git a/src/lib/components/toast/useMutationsHandler.test.tsx b/src/lib/components/toast/useMutationsHandler.test.tsx new file mode 100644 index 0000000000..efc55a377f --- /dev/null +++ b/src/lib/components/toast/useMutationsHandler.test.tsx @@ -0,0 +1,131 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { MutationConfig, useMutationsHandler } from './useMutationsHandler'; +import { useToast } from './ToastProvider'; + +jest.mock('./ToastProvider', () => ({ + useToast: jest.fn(), +})); + +const mockUseToast = useToast as jest.MockedFunction; + +describe('useMutationsHandler', () => { + const mainMutation = { + mutation: { + isLoading: false, + isSuccess: true, + isError: false, + isIdle: false, + }, + name: 'mutation1', + } as MutationConfig; + const dependantMutations = [ + { + mutation: { + isLoading: false, + isSuccess: true, + isError: false, + isIdle: false, + }, + name: 'mutation2', + }, + { + mutation: { + isLoading: false, + isSuccess: false, + isError: false, + isIdle: true, + }, + name: 'mutation3', + isPrimary: false, + }, + ] as MutationConfig[]; + + const messageDescriptionBuilder = jest.fn(() => 'message'); + + it('should call onPrimarySuccess when a primary mutation succeeds', async () => { + const showToast = jest.fn(); + const onMainMutationSuccess = jest.fn(); + + mockUseToast.mockImplementation(() => ({ + showToast, + })); + + const { waitFor } = renderHook(() => + useMutationsHandler({ + mainMutation, + dependantMutations, + messageDescriptionBuilder, + onMainMutationSuccess, + }), + ); + + await act(async () => { + await waitFor(() => { + expect(onMainMutationSuccess).toHaveBeenCalled(); + }); + }); + }); + + it('should show a success toast when all mutations succeed', async () => { + const showToast = jest.fn(); + + mockUseToast.mockImplementation(() => ({ + showToast, + })); + + const { waitFor } = renderHook(() => + useMutationsHandler({ + mainMutation, + dependantMutations, + messageDescriptionBuilder, + }), + ); + + await act(async () => { + await waitFor(() => { + expect(showToast).toHaveBeenCalledWith({ + open: true, + status: 'success', + message: 'message', + }); + }); + }); + }); + + it('should show an error toast when at least one mutation fails', async () => { + const showToast = jest.fn(); + + mockUseToast.mockImplementation(() => ({ + showToast, + })); + + const mutationsWithError = [ + { + mutation: { + isLoading: false, + isSuccess: false, + isError: true, + }, + name: 'mutation4', + }, + ] as MutationConfig[]; + + const { waitFor } = renderHook(() => + useMutationsHandler({ + mainMutation, + dependantMutations: mutationsWithError, + messageDescriptionBuilder, + }), + ); + + await act(async () => { + await waitFor(() => { + expect(showToast).toHaveBeenCalledWith({ + open: true, + status: 'error', + message: 'message', + }); + }); + }); + }); +}); diff --git a/src/lib/components/toast/useMutationsHandler.ts b/src/lib/components/toast/useMutationsHandler.ts new file mode 100644 index 0000000000..c74dbbdfc4 --- /dev/null +++ b/src/lib/components/toast/useMutationsHandler.ts @@ -0,0 +1,96 @@ +import { ReactNode, useCallback, useEffect } from 'react'; +import { UseMutationResult } from 'react-query'; +import { useToast } from './ToastProvider'; + +export type MutationConfig = { + mutation: UseMutationResult; + name: string; +}; + +type DescriptionBuilder = { + data?: Data; + error?: unknown; + name: string; +}; + +type MutationsHandlerProps = { + mainMutation: MutationConfig; + dependantMutations?: MutationConfig[]; + messageDescriptionBuilder: ( + successMutations: DescriptionBuilder[], + errorMutations: DescriptionBuilder[], + ) => ReactNode; + toastStyles?: React.CSSProperties; + onMainMutationSuccess?: () => void; +}; + +export const useMutationsHandler = ({ + mainMutation, + dependantMutations, + messageDescriptionBuilder, + toastStyles, + onMainMutationSuccess, +}: MutationsHandlerProps) => { + const { showToast } = useToast(); + const mutations = [ + mainMutation, + ...(dependantMutations ? dependantMutations : []), + ]; + + const handleMutationsCompletion = useCallback(async () => { + const results = await Promise.all(mutations.map((m) => m.mutation)); + + const loadingMutations = mutations.filter( + (_, index) => results[index].isLoading, + ); + const successMutations = mutations.filter( + (_, index) => results[index].isSuccess, + ); + const errorMutations = mutations.filter( + (_, index) => results[index].isError, + ); + + const successDescriptionBuilder = successMutations.map((m) => ({ + data: m.mutation?.data, + error: m.mutation?.error, + name: m.name, + })); + + const errorDescriptionBuilder = errorMutations.map((m) => ({ + data: m.mutation?.data, + error: m.mutation?.error, + name: m.name, + })); + + if (loadingMutations.length === 0) { + if (errorMutations.length > 0) { + onMainMutationSuccess?.(); + showToast({ + open: true, + status: 'error', + message: messageDescriptionBuilder( + successDescriptionBuilder, + errorDescriptionBuilder, + ), + style: toastStyles, + }); + return; + } else if (successMutations.length > 0) { + onMainMutationSuccess?.(); + showToast({ + open: true, + status: 'success', + message: messageDescriptionBuilder( + successDescriptionBuilder, + errorDescriptionBuilder, + ), + style: toastStyles, + }); + } + } + }, [JSON.stringify(mutations)]); + + useEffect(() => { + handleMutationsCompletion(); + }, [handleMutationsCompletion]); +}; diff --git a/src/lib/components/toast/useToastParameters.ts b/src/lib/components/toast/useToastParameters.ts new file mode 100644 index 0000000000..ef5fcd4139 --- /dev/null +++ b/src/lib/components/toast/useToastParameters.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef } from 'react'; + +interface ToastParameters { + duration?: number | null; + open?: boolean; + onClose?: () => void; +} + +export function useToastParameters(params: ToastParameters) { + const { duration = null, open, onClose } = params; + + const timerAutoHide = useRef>(); + + useEffect(() => { + if (!open) { + return undefined; + } + + function handleKeyDown(nativeEvent: KeyboardEvent) { + if (!nativeEvent.defaultPrevented) { + if (nativeEvent.key === 'Escape' || nativeEvent.key === 'Esc') { + onClose?.(); + } + } + } + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open, onClose]); + + const setAutoHideTimer = useCallback( + (autoHideDurationParam: number | null) => { + if (!onClose || autoHideDurationParam == null) { + return; + } + + clearTimeout(timerAutoHide.current); + timerAutoHide.current = setTimeout(() => { + onClose?.(); + }, autoHideDurationParam); + }, + [onClose], + ); + + useEffect(() => { + if (open) { + setAutoHideTimer(duration); + } + + return () => { + clearTimeout(timerAutoHide.current); + }; + }, [open, duration, setAutoHideTimer]); + + return { params }; +} diff --git a/src/lib/index.ts b/src/lib/index.ts index eb3119aae0..55d1fd7e4c 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -69,3 +69,6 @@ export { Form, FormSection, FormGroup } from './components/form/Form.component'; export { FormattedDateTime } from './components/date/FormattedDateTime'; export { IconHelp } from './components/IconHelper'; export { Dropzone } from './components/dropzone/Dropzone'; +export { Toast } from './components/toast/Toast.component'; +export { ToastProvider, useToast } from './components/toast/ToastProvider'; +export { useMutationsHandler } from './components/toast/useMutationsHandler'; diff --git a/src/lib/style/theme.ts b/src/lib/style/theme.ts index 90d54c36c8..598e711200 100644 --- a/src/lib/style/theme.ts +++ b/src/lib/style/theme.ts @@ -31,8 +31,11 @@ export const grayLightest = '#E9EBED'; export const defaultTheme = { darkRebrand: { statusHealthy: '#0AADA6', + statusHealthyRGB: '10,173,166', statusWarning: '#F8F32B', + statusWarningRGB: '248,243,43', statusCritical: '#E84855', + statusCriticalRGB: '232,72,85', selectedActive: '#037AFF', highlight: '#1A3C75', border: '#4A4A4A', diff --git a/stories/toast.stories.tsx b/stories/toast.stories.tsx new file mode 100644 index 0000000000..459972fdfc --- /dev/null +++ b/stories/toast.stories.tsx @@ -0,0 +1,159 @@ +import React, { useState } from 'react'; +import { BasicText } from '../src/lib'; +import { Button } from '../src/lib/components/buttonv2/Buttonv2.component'; +import { Icon } from '../src/lib/components/icon/Icon.component'; +import { + Toast, + ToastProps, + useGetBackgroundColor, +} from '../src/lib/components/toast/Toast.component'; + +export default { + title: 'Components/Toast', + component: Toast, + tags: ['autodocs'], + argTypes: { + open: { + control: { + disable: true, + }, + }, + message: { + control: { + disable: true, + description: 'The message to display in the toast', + }, + }, + status: { + control: 'radio', + options: ['success', 'error', 'warning', 'info'], + description: 'The status of the toast', + }, + position: { + control: 'select', + options: [ + 'top-center', + 'top-left', + 'top-right', + 'bottom-center', + 'bottom-left', + 'bottom-right', + ], + description: 'The position of the toast', + }, + autoDismiss: { + control: 'boolean', + description: 'Whether the toast should dismiss automatically', + }, + duration: { + control: 'number', + description: 'The duration of the toast', + }, + icon: { + control: { + disable: true, + description: 'The icon to display in the toast', + }, + }, + width: { + control: { + disable: true, + description: 'The width of the toast', + }, + }, + withProgressBar: { + control: 'boolean', + description: 'Whether the toast should display a progress bar', + }, + progressColor: { + control: { + disable: true, + }, + }, + style: { + control: { + disable: true, + }, + }, + }, +}; + +const Template = (args: Omit) => { + const [open, setOpen] = useState(false); + const color = useGetBackgroundColor(args.status || 'info'); + const iconName = + args.status === 'error' + ? 'Times-circle' + : args.status === 'warning' + ? 'Exclamation-circle' + : args.status === 'success' + ? 'Check-circle' + : 'Info-circle'; + return ( + <> + {!open && ( +