Last active
February 14, 2024 18:28
-
-
Save mwarman/d68b6bc0f04ee45af20da89b41b588c4 to your computer and use it in GitHub Desktop.
React Context and Components for displaying toast messages in an application. Includes Jest unit tests using React Testing Library.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import dayjs from 'dayjs'; | |
import userEvent from '@testing-library/user-event'; | |
import { render, screen, waitFor } from 'test/test-utils'; | |
import { server } from 'test/mocks/server'; | |
import { ToastDetail } from 'providers/ToastsProvider'; | |
import { toastFixture } from '__fixtures__/toasts'; | |
import Toast from '../Toast'; | |
describe('Toast', () => { | |
const mockDismiss = jest.fn(); | |
beforeAll(() => { | |
server.listen(); | |
}); | |
afterEach(() => { | |
server.resetHandlers(); | |
}); | |
afterAll(() => { | |
server.close(); | |
}); | |
it('should render successfully', async () => { | |
render(<Toast toast={toastFixture} dismiss={mockDismiss} />); | |
await screen.findByTestId('toast'); | |
expect(screen.getByTestId('toast')).toBeDefined(); | |
}); | |
it('should use custom testId', async () => { | |
render(<Toast toast={toastFixture} dismiss={mockDismiss} testId="custom-testId" />); | |
await screen.findByTestId('custom-testId'); | |
expect(screen.getByTestId('custom-testId')).toBeDefined(); | |
}); | |
it('should use custom className', async () => { | |
render(<Toast toast={toastFixture} dismiss={mockDismiss} className="custom-className" />); | |
await screen.findByTestId('toast'); | |
expect(screen.getByTestId('toast').classList).toContain('custom-className'); | |
}); | |
it('should call dismiss function when button clicked', async () => { | |
render(<Toast toast={toastFixture} dismiss={mockDismiss} />); | |
await screen.findByTestId('toast-button-dismiss'); | |
await userEvent.click(screen.getByTestId('toast-button-dismiss')); | |
await waitFor(() => expect(mockDismiss).toHaveBeenCalled()); | |
}); | |
it('should auto dismiss', async () => { | |
const toast: ToastDetail = { | |
...toastFixture, | |
createdAt: dayjs().toISOString(), | |
isAutoDismiss: true, | |
}; | |
render(<Toast toast={toast} dismiss={mockDismiss} />); | |
await waitFor(() => expect(mockDismiss).toHaveBeenCalled(), { timeout: 5000 }); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect } from 'react'; | |
import { ButtonVariant, PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; | |
import classNames from 'classnames'; | |
import dayjs from 'dayjs'; | |
import { animated, useSpring } from '@react-spring/web'; | |
import { ToastDetail } from 'providers/ToastsProvider'; | |
import { useConfig } from 'providers/ConfigProvider'; | |
import Icon from 'components/Icon/Icon'; | |
import Button from 'components/Button/Button'; | |
/** | |
* Properties for the `Toast` component. | |
* @param {function} dismiss - A function called when the `Toast` dismisses. | |
* @param {ToastDetail} toast - The `Toast`. | |
* @see {@link PropsWithClassName} | |
* @see {@link PropsWithTestId} | |
*/ | |
interface ToastProps extends PropsWithClassName, PropsWithTestId { | |
dismiss: () => void; | |
toast: ToastDetail; | |
} | |
/** | |
* The `Toast` component renders a small, dismissible message to the user. | |
* | |
* Toast messages are typically used to inform the user of something that | |
* happened in the background such as saving information. Or they are | |
* used when some adverse action happens, such as an error. | |
* @param {ToastProps} props - Component properties, `ToastProps`. | |
* @returns {JSX.Element} JSX | |
*/ | |
const Toast = ({ className, dismiss, testId = 'toast', toast }: ToastProps): JSX.Element => { | |
const config = useConfig(); | |
const [springs, api] = useSpring(() => ({ | |
from: { opacity: 1, x: 0 }, | |
})); | |
const doDismiss = (): void => { | |
api.start({ | |
to: { opacity: 0, x: -1000 }, | |
onRest: () => { | |
dismiss(); | |
}, | |
}); | |
}; | |
useEffect(() => { | |
if (toast.isAutoDismiss) { | |
const dismissInterval = setInterval(() => { | |
const dismissAt = dayjs(toast.createdAt).add( | |
config.REACT_APP_TOAST_AUTO_DISMISS_MILLIS, | |
'millisecond', | |
); | |
if (dayjs().isAfter(dismissAt)) { | |
doDismiss(); | |
} | |
}, 500); | |
return () => clearInterval(dismissInterval); | |
} | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, [toast, config.REACT_APP_TOAST_AUTO_DISMISS_MILLIS]); | |
return ( | |
<animated.div | |
className={classNames('max-w-sm rounded bg-neutral-200 dark:bg-neutral-600', className)} | |
data-testid={testId} | |
style={{ ...springs }} | |
> | |
<div className="flex min-h-12 items-center p-2"> | |
<div className="grow text-sm" data-testid={`${testId}-text`}> | |
{toast.text} | |
</div> | |
<Button | |
variant={ButtonVariant.Text} | |
className="!p-0" | |
onClick={() => doDismiss()} | |
data-testid={`${testId}-button-dismiss`} | |
> | |
<Icon name="cancel" testId={`${testId}-icon-dismiss`} /> | |
</Button> | |
</div> | |
</animated.div> | |
); | |
}; | |
export default Toast; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { server } from 'test/mocks/server'; | |
import { render, screen, waitFor } from 'test/test-utils'; | |
import * as ToastsProvider from 'providers/ToastsProvider'; | |
import { toastFixture } from '__fixtures__/toasts'; | |
import Toasts from '../Toasts'; | |
import userEvent from '@testing-library/user-event'; | |
describe('Toasts', () => { | |
const useToastsSpy = jest.spyOn(ToastsProvider, 'useToasts'); | |
const mockRemoveToast = jest.fn(); | |
beforeAll(() => { | |
server.listen(); | |
}); | |
beforeEach(() => { | |
useToastsSpy.mockReturnValue({ | |
createToast: jest.fn(), | |
removeToast: mockRemoveToast, | |
toasts: [toastFixture], | |
}); | |
}); | |
afterEach(() => { | |
server.resetHandlers(); | |
}); | |
afterAll(() => { | |
server.close(); | |
}); | |
it('should render successfully', async () => { | |
render(<Toasts />); | |
await screen.findByTestId('toasts'); | |
expect(screen.getByTestId('toasts')).toBeDefined(); | |
}); | |
it('should use custom testId', async () => { | |
render(<Toasts testId="custom-testId" />); | |
await screen.findByTestId('custom-testId'); | |
expect(screen.getByTestId('custom-testId')).toBeDefined(); | |
}); | |
it('should render toasts', async () => { | |
render(<Toasts />); | |
await screen.findByTestId(`toast-${toastFixture.id}`); | |
expect(screen.getByTestId(`toast-${toastFixture.id}`)).toBeDefined(); | |
}); | |
it('should call removeToast with id', async () => { | |
render(<Toasts />); | |
await screen.findByTestId(`toast-${toastFixture.id}-button-dismiss`); | |
await userEvent.click(screen.getByTestId(`toast-${toastFixture.id}-button-dismiss`)); | |
await waitFor(() => expect(mockRemoveToast).toHaveBeenCalledWith(toastFixture.id)); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { PropsWithTestId } from '@leanstacks/react-common'; | |
import { useToasts } from 'providers/ToastsProvider'; | |
import Toast from './Toast'; | |
/** | |
* Properties for the `Toasts` component. | |
* @see {@link PropsWithTestId} | |
*/ | |
interface ToastsProps extends PropsWithTestId {} | |
/** | |
* The `Toasts` component renders a container for a list of `Toast` | |
* components. | |
* @param {ToastsProps} props - Component properties, `ToastsProps`. | |
* @returns {JSX.Element} JSX | |
*/ | |
const Toasts = ({ testId = 'toasts' }: ToastsProps): JSX.Element => { | |
const { removeToast, toasts } = useToasts(); | |
return ( | |
<div className="fixed inset-x-0 bottom-0 left-0 z-[9999]" data-testid={testId}> | |
{toasts.map((toast) => ( | |
<Toast | |
key={toast.id} | |
toast={toast} | |
dismiss={() => removeToast(toast.id)} | |
className="mx-8 mb-4" | |
testId={`toast-${toast.id}`} | |
/> | |
))} | |
</div> | |
); | |
}; | |
export default Toasts; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
render as renderWithoutWrapper, | |
renderHook as renderHookWithoutWrapper, | |
} from '@testing-library/react'; | |
import { act, renderHook, screen, waitFor } from 'test/test-utils'; | |
import { server } from 'test/mocks/server'; | |
import ToastsProvider, { useToasts } from 'providers/ToastsProvider'; | |
describe('ToastsProvider', () => { | |
it('should render successfully', async () => { | |
renderWithoutWrapper( | |
<ToastsProvider> | |
<div data-testid="provider-toasts"></div> | |
</ToastsProvider>, | |
); | |
await screen.findByTestId('provider-toasts'); | |
expect(screen.getByTestId('provider-toasts')).toBeDefined(); | |
}); | |
}); | |
describe('useToasts', () => { | |
beforeAll(() => { | |
server.listen(); | |
}); | |
afterEach(() => { | |
server.resetHandlers(); | |
}); | |
afterAll(() => { | |
server.close(); | |
}); | |
it('should return the context', async () => { | |
const { result } = renderHook(() => useToasts()); | |
await waitFor(() => expect(result.current).not.toBeNull()); | |
expect(result.current).not.toBeNull(); | |
expect(Array.isArray(result.current.toasts)).toBe(true); | |
expect(result.current.toasts.length).toBe(0); | |
expect(typeof result.current.createToast).toBe('function'); | |
expect(typeof result.current.removeToast).toBe('function'); | |
}); | |
it('should create a toast', async () => { | |
const { result } = renderHook(() => useToasts()); | |
await waitFor(() => expect(result.current).not.toBeNull()); | |
expect(result.current.toasts.length).toBe(0); | |
act(() => result.current.createToast({ text: 'toast', isAutoDismiss: false })); | |
await waitFor(() => expect(result.current.toasts.length).toBe(1)); | |
}); | |
it('should remove a toast', async () => { | |
const { result } = renderHook(() => useToasts()); | |
await waitFor(() => expect(result.current).not.toBeNull()); | |
expect(result.current.toasts.length).toBe(0); | |
act(() => result.current.createToast({ text: 'toast', isAutoDismiss: false })); | |
await waitFor(() => expect(result.current.toasts.length).toBe(1)); | |
act(() => result.current.removeToast(result.current.toasts[0].id)); | |
await waitFor(() => expect(result.current.toasts.length).toBe(0)); | |
}); | |
it('should throw error when not within provider', async () => { | |
expect(() => renderHookWithoutWrapper(() => useToasts())).toThrow( | |
/useToasts hook must be used within/, | |
); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { Dispatch, PropsWithChildren, useContext, useMemo, useReducer } from 'react'; | |
import { v4 as uuid } from 'uuid'; | |
import dayjs from 'dayjs'; | |
/** | |
* Describes the attributes of a single Toast. | |
*/ | |
export interface ToastDetail { | |
id: string; | |
text: string; | |
createdAt: string; | |
isAutoDismiss: boolean; | |
} | |
/** | |
* A DTO type which describes the attributes to create a new Toast. | |
*/ | |
export type CreateToastDTO = Pick<ToastDetail, 'text' | 'isAutoDismiss'>; | |
/** | |
* The `value` provided by the `ToastsContext`. | |
*/ | |
export interface ToastsContextValue { | |
toasts: ToastDetail[]; | |
createToast: (toast: CreateToastDTO) => void; | |
removeToast: (id: string) => void; | |
} | |
/** | |
* The `ToastsContext` reducer `state`. | |
*/ | |
type ToastsContextState = { | |
toasts: ToastDetail[]; | |
}; | |
/** | |
* The available `ToastsContext` action types. | |
*/ | |
enum ToastAction { | |
Create = 'Create', | |
Remove = 'Remove', | |
} | |
/** | |
* The `ToastContext` action type definitions. Each action consists | |
* of a `type` and a `payload`. | |
* | |
* The `type` indicates the specific action to be performed. | |
* | |
* The `payload` contains information specific to the `type` of action | |
* requested. | |
*/ | |
type ToastsContextAction = | |
| { type: ToastAction.Create; payload: ToastDetail } | |
| { type: ToastAction.Remove; payload: string }; | |
/** | |
* The default `state` of the reducer. | |
*/ | |
const DEFAULT_STATE: ToastsContextState = { toasts: [] }; | |
/** | |
* The reducer function mutates the state as actions are dispatched. | |
* @param {ToastsContextState} state - The current reducer state. | |
* @param {ToastsContextAction} action - The action to be applied to the state. | |
* @returns {ToastsContextState} The updated state. | |
*/ | |
const reducer = (state: ToastsContextState, action: ToastsContextAction): ToastsContextState => { | |
const { payload, type } = action; | |
switch (type) { | |
case ToastAction.Create: | |
return { | |
...state, | |
toasts: [...state.toasts, payload], | |
}; | |
case ToastAction.Remove: | |
return { | |
...state, | |
toasts: state.toasts.filter((toast) => toast.id !== payload), | |
}; | |
default: | |
return state; | |
} | |
}; | |
/** | |
* Creates the action functions which may be used to request mutations to the | |
* `state`. | |
* @param dispatch - The reducer dispatch function. | |
* @returns An object whose properties are action functions. | |
*/ | |
const actions = (dispatch: Dispatch<ToastsContextAction>) => { | |
const createToast = ({ text, isAutoDismiss }: CreateToastDTO): void => { | |
dispatch({ | |
type: ToastAction.Create, | |
payload: { | |
id: uuid(), | |
createdAt: dayjs().toISOString(), | |
isAutoDismiss, | |
text, | |
}, | |
}); | |
}; | |
const removeToast = (id: string): void => { | |
dispatch({ | |
type: ToastAction.Remove, | |
payload: id, | |
}); | |
}; | |
return { | |
createToast, | |
removeToast, | |
}; | |
}; | |
/** | |
* The `ToastsContext` instance. | |
*/ | |
const ToastsContext = React.createContext<ToastsContextValue | undefined>(undefined); | |
/** | |
* The `ToastsProvider` React component creates, maintains, and provides | |
* access to the `ToastsContext` value. | |
* @param {PropsWithChildren} props - Component properties, `PropsWithChildren`. | |
* @returns {JSX.Element} JSX | |
*/ | |
const ToastsProvider = ({ children }: PropsWithChildren): JSX.Element => { | |
const [{ toasts }, dispatch] = useReducer(reducer, DEFAULT_STATE); | |
const value = useMemo<ToastsContextValue>(() => { | |
const { createToast, removeToast } = actions(dispatch); | |
return { | |
toasts, | |
createToast, | |
removeToast, | |
}; | |
}, [toasts]); | |
return <ToastsContext.Provider value={value}>{children}</ToastsContext.Provider>; | |
}; | |
export default ToastsProvider; | |
/** | |
* The `useToasts` hook returns the current `ToastsContext` value. | |
* @returns {ToastsContextValue} The current `ToastContext` value, `ToastsContextValue`. | |
*/ | |
export const useToasts = (): ToastsContextValue => { | |
const context = useContext(ToastsContext); | |
if (!context) { | |
throw new Error('useToasts hook must be used within a ToastsProvider'); | |
} | |
return context; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment