Skip to content

Commit

Permalink
Merge pull request #29 from osstotalsoft/feature/Toast
Browse files Browse the repository at this point in the history
rewrite toast
  • Loading branch information
DCosti authored May 30, 2023
2 parents 62c2f91 + 2b5bb9b commit bc5d551
Show file tree
Hide file tree
Showing 18 changed files with 1,632 additions and 519 deletions.
2 changes: 2 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import './doc-root.css'
import React from 'react'
import type { Preview } from '@storybook/react'
import { ThemeProvider } from '@mui/material/styles'
import { ToastContainer } from '../src/components/feedback/Toast'
import getTheme from '../src/components/themes/index'
import { DocsPage } from './DocsPage'

const withThemeProvider = (Story, context) => {
const theme = getTheme(context)
return (
<ThemeProvider theme={theme}>
<ToastContainer />
<Story />
</ThemeProvider>
)
Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"cSpell.words": [
"autodocs",
"storybuild",
"Toastify",
"uuidv"
],
"licenser.license": "Custom",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"react-dom": "18.2.0",
"react-number-format": "^4.9.2",
"react-router-dom": "^6.10.0",
"react-toastify": "^8.2.0",
"ts-toolbelt": "^9.6.0"
},
"devDependencies": {
Expand Down
112 changes: 112 additions & 0 deletions src/components/feedback/Toast/Toast.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
import React from 'react'
import useToast from './useToast'
import { render } from 'testingUtils'
import Button from 'components/buttons/Button'
import usePromiseToast from './usePromiseToast'

describe('Toast', () => {
it.each([
{
variant: 'success',
transitionType: 'Slide',
position: 'top-center',
expectedVariant: 'Toastify__toast--success',
expectedTransition: 'Toastify__slide-enter--top-center ',
expectedPosition: 'Toastify__toast-container--top-center'
},
{
variant: 'info',
transitionType: 'Zoom',
position: 'top-right',
expectedVariant: 'Toastify__toast--info',
expectedTransition: 'Toastify__zoom-enter ',
expectedPosition: 'Toastify__toast-container--top-right'
},
{
variant: 'warning',
transitionType: 'Bounce',
position: 'bottom-right',
expectedVariant: 'Toastify__toast--warning',
expectedTransition: 'Toastify__bounce-enter--bottom-right',
expectedPosition: 'Toastify__toast-container--bottom-right'
},
{
variant: 'error',
transitionType: 'Flip',
position: 'bottom-center',
expectedVariant: 'Toastify__toast--error',
expectedTransition: 'Toastify__flip-enter',
expectedPosition: 'Toastify__toast-container--bottom-center'
},
{
variant: null,
transitionType: 'Slide',
position: 'top-center',
expectedVariant: 'Toastify__toast--default',
expectedTransition: 'Toastify__slide-enter--top-center ',
expectedPosition: 'Toastify__toast-container--top-center'
}
])(
'Should render a toast of type: $variant with the correct specifications',
async ({ variant, transitionType, position, expectedVariant, expectedTransition, expectedPosition }) => {
const { result } = renderHook(() => useToast())

render(
<Button
onClick={() => result.current('This is a message!', variant as any, transitionType as any, position as any)}
/>
)
fireEvent.click(screen.getByRole('button'))

await waitFor(() => expect(screen.getByRole('alert').parentNode).toHaveClass(expectedVariant, expectedTransition))
await waitFor(() => expect(screen.getByRole('alert').parentNode.parentNode).toHaveClass(expectedPosition))
}
)

it('Should close the toast when click-ing the x button', async () => {
const { result } = renderHook(() => useToast())

render(<Button onClick={() => result.current('This is a success message!', 'success')} />)

fireEvent.click(screen.getByRole('button'))
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument())

fireEvent.click(screen.getByLabelText('close'))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
})

it('Error toast should not close when click-ing on its container', async () => {
const { result } = renderHook(() => useToast())

render(<Button onClick={() => result.current('This is an error message!', 'error')} />)

fireEvent.click(screen.getByRole('button'))
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument())

fireEvent.click(screen.getByRole('alert'))
await waitFor(() => expect(screen.queryByRole('alert')).toBeInTheDocument())
})
})

describe('Promise toast', () => {
it('Promise toast should behave as expected', async () => {
const resolveAfter3Sec = () => new Promise((_resolve, reject) => setTimeout(reject, 3000))
const { result } = renderHook(() => usePromiseToast())

render(
<Button
onClick={() =>
result.current(resolveAfter3Sec(), 'Promise is pending', 'Promise resolved 👌', 'Promise rejected 🤯')
}
/>
)
fireEvent.click(screen.getByRole('button'))

await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument())
await waitFor(() => expect(screen.getByRole('alert').parentNode).toHaveClass('Toastify__toast--default'))
await waitFor(() => expect(screen.getByRole('alert').parentNode).toHaveClass('Toastify__toast--error'), {
timeout: 4000
})
})
})
69 changes: 69 additions & 0 deletions src/components/feedback/Toast/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react'
import PropTypes from 'prop-types'
import { ToastContainer as ReactToastify, toast } from 'react-toastify'
import Container, { classes } from './ToastStyles'
import 'react-toastify/dist/ReactToastify.css'
import { ToastContainerProps } from './types'

/**
* Toast provide brief notifications.
*/

const ToastContainer: React.FC<ToastContainerProps> = ({
position = toast.POSITION.TOP_CENTER,
autoClose = 3000,
newestOnTop = true,
transitionType = 'Slide',
limit = 5,
...rest
}) => {
return (
<Container>
<ReactToastify
className={classes.toastWrapper}
position={position}
autoClose={autoClose}
newestOnTop={newestOnTop}
transition={transitionType as any}
theme="colored"
limit={limit}
{...rest}
/>
</Container>
)
}

ToastContainer.propTypes = {
/**
* Set the delay in ms to close the toast automatically.
* Use `false` to prevent the toast from closing.
* @default 3000
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
autoClose: PropTypes.oneOf([PropTypes.number, false]),
/**
* Limit the number of toast displayed at the same time
* @default 5
*/
limit: PropTypes.number,
/**
* Set the position to use.
* @default 'top-center'
*/
position: PropTypes.oneOf(['top-right', 'top-center', 'top-left', 'bottom-right', 'bottom-center', 'bottom-left']),
/**
* The appearance effect.
* @default Slide
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
transitionType: PropTypes.oneOf(['Slide', 'Bounce', 'Zoom', 'Flip']),
/**
* Whether or not to display the newest toast on top.
* @default true
*/
newestOnTop: PropTypes.bool
}

export default ToastContainer
57 changes: 57 additions & 0 deletions src/components/feedback/Toast/ToastStyles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { styled } from '@mui/material/styles'

const PREFIX = 'StyledToast'

export const classes = {
default: `${PREFIX}-default`,
success: `${PREFIX}-success`,
info: `${PREFIX}-info`,
error: `${PREFIX}-error`,
warning: `${PREFIX}-warning`,
toastWrapper: `${PREFIX}-toastWrapper`
}

const Container = styled('div')(({ theme }) => ({
[`& .${classes.default}`]: { borderRadius: '6px', padding: '6px 20px' },
[`& .${classes.success}`]: {
'--toastify-color-success': theme.palette.success.main,
'--toastify-text-color-success': theme.palette.success.contrastText,
'--toastify-icon-color-success': theme.palette.success.main,
'--toastify-color-progress-success': theme.palette.success.main
},
[`& .${classes.info}`]: {
'--toastify-color-info': theme.palette.info.main,
'--toastify-text-color-info': theme.palette.info.contrastText,
'--toastify-icon-color-info': theme.palette.info.main,
'--toastify-color-progress-info': theme.palette.info.main
},
[`& .${classes.error}`]: {
'--toastify-color-error': theme.palette.error.main,
'--toastify-text-color-error': theme.palette.error.contrastText,
'--toastify-icon-color-error': theme.palette.error.main,
'--toastify-color-progress-error': theme.palette.error.main
},
[`& .${classes.warning}`]: {
'--toastify-color-warning': theme.palette.warning.main,
'--toastify-text-color-warning': theme.palette.warning.contrastText,
'--toastify-icon-color-warning': theme.palette.warning.main,
'--toastify-color-progress-warning': theme.palette.warning.main
},
[`& .${classes.toastWrapper}`]: {
borderRadius: '6px',
width: '350px',
overflowWrap: 'anywhere'
},
['.Toastify__close-button']: {
background: 'transparent',
outline: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
opacity: 1,
transition: '0.3s ease',
alignSelf: 'auto'
}
}))

export default Container
3 changes: 3 additions & 0 deletions src/components/feedback/Toast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as ToastContainer } from './ToastContainer'
export { default as useToast } from './useToast'
export { default as usePromiseToast } from './usePromiseToast'
11 changes: 11 additions & 0 deletions src/components/feedback/Toast/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright (c) TotalSoft.
// This source code is licensed under the MIT license.
import { ToastContainerProps as ReactToastifyProps } from 'react-toastify'

export interface ToastContainerProps extends Omit<ReactToastifyProps, 'transition'> {
/**
* The appearance effect.
* @default Slide
*/
transitionType?: 'Slide' | 'Bounce' | 'Zoom' | 'Flip'
}
27 changes: 27 additions & 0 deletions src/components/feedback/Toast/usePromiseToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useCallback } from 'react'
import { toast } from 'react-toastify'
import { classes } from './ToastStyles'

const usePromiseToast = () => {
return useCallback(
/**
*
* @param {Promise} promise The promise function
* @param {(String|Object)} pending The message to be shown while promise in pending or the entire object with all the configurations
* @param {(String|Object)} success The message to be shown when promise completed successfully or the entire object with all the configurations
* @param {(String|Object)} error The message to be shown when promise was rejected or the entire object with all the configurations
*/
(promise: Promise<unknown>, pending: string | object, success: string | object, error: string | object) => {
toast.promise(
promise,
{ pending, success, error },
{
className: `${classes.success} ${classes.default} ${classes.info} ${classes.error} ${classes.error} ${classes.default} `
}
)
},
[]
)
}

export default usePromiseToast
58 changes: 58 additions & 0 deletions src/components/feedback/Toast/useToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useCallback } from 'react'
import { toast, Slide, Bounce, Flip, Zoom } from 'react-toastify'
import { classes } from './ToastStyles'
import cx from 'classnames'
import { cond, equals, always, T } from 'ramda'

const useToast = () => {
return useCallback(
/**
*
* @param {String} message The text to be displayed
* @param {('success'|'info'|'warning'|'error')} variant The type of the toast
* @param {('Slide' | 'Bounce' | 'Zoom' | 'Flip')} transitionType The appearance effect
* @param {('top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left')} position Where to be displayed on the page
* @param {(Number| false)} autoClose Delay in ms to close the toast
*/
(
message: string,
variant?: 'success' | 'info' | 'warning' | 'error',
transitionType?: 'Slide' | 'Bounce' | 'Zoom' | 'Flip',
position?: 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left',
autoClose: any = variant !== 'error'
) => {
const toastClasses = cx({
[classes[variant]]: variant,
[classes['default']]: true
})
const getTransitionType = cond([
[equals('Slide'), always(Slide)],
[equals('Bounce'), always(Bounce)],
[equals('Flip'), always(Flip)],
[equals('Zoom'), always(Zoom)],
[T, always(Slide)]
])
const options = { autoClose, transition: getTransitionType(transitionType), position, className: toastClasses }
switch (variant) {
case 'error':
toast.error(message, { ...options, autoClose: false, closeOnClick: false, draggable: false })
break
case 'info':
toast.info(message, options)
break
case 'success':
toast.success(message, options)
break
case 'warning':
toast.warn(message, options)
break
default:
toast(message, options)
break
}
},
[]
)
}

export default useToast
5 changes: 5 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,10 @@ export * from './feedback/NotFound'
export { default as FakeText } from './feedback/FakeText'
export * from './feedback/FakeText'

export { default as ToastContainer } from './feedback/Toast/ToastContainer'
export { default as useToast } from './feedback/Toast/useToast'
export { default as usePromiseToast } from './feedback/Toast/usePromiseToast'
export * from './feedback/Toast'

export { default as getTheme } from './themes/index'
export * from './themes/index'
2 changes: 1 addition & 1 deletion src/stories/feedback/FakeText/FakeText.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
args: { lines: 4, onPaper: false }
args: { lines: 4, onPaper: false, width: '400px' }
}

/**
Expand Down
Loading

0 comments on commit bc5d551

Please sign in to comment.