Skip to content

Instantly share code, notes, and snippets.

@mwarman
Last active February 14, 2024 18:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mwarman/d68b6bc0f04ee45af20da89b41b588c4 to your computer and use it in GitHub Desktop.
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.
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 });
});
});
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;
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));
});
});
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;
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/,
);
});
});
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