Introduce new Toast module
This commit is contained in:
parent
778b63ab56
commit
01eccc897b
6 changed files with 440 additions and 0 deletions
208
app/soapbox/__tests__/toast.test.tsx
Normal file
208
app/soapbox/__tests__/toast.test.tsx
Normal file
|
@ -0,0 +1,208 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import { AxiosError } from 'axios';
|
||||
import React from 'react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { act, screen } from 'soapbox/jest/test-helpers';
|
||||
|
||||
function renderApp() {
|
||||
const { Toaster } = require('react-hot-toast');
|
||||
const toast = require('../toast').default;
|
||||
|
||||
return {
|
||||
toast,
|
||||
...render(
|
||||
<IntlProvider locale='en'>
|
||||
<Toaster />,
|
||||
</IntlProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
(console.error as any).mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
(console.error as any).mockRestore();
|
||||
});
|
||||
|
||||
describe('toasts', () =>{
|
||||
it('renders successfully', async() => {
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.success('hello');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toast-message')).toHaveTextContent('hello');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionable button', () => {
|
||||
it('renders the button', async() => {
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.success('hello', { action: () => null, actionLabel: 'click me' });
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('toast-action')).toHaveTextContent('click me');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render the button', async() => {
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.success('hello');
|
||||
});
|
||||
|
||||
expect(screen.queryAllByTestId('toast-action')).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('showAlertForError()', () => {
|
||||
const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), undefined, null, {
|
||||
data: {
|
||||
error: message,
|
||||
},
|
||||
statusText: String(status),
|
||||
status,
|
||||
headers: {},
|
||||
config: {},
|
||||
});
|
||||
|
||||
describe('with a 502 status code', () => {
|
||||
it('renders the correct message', async() => {
|
||||
const message = 'The server is down';
|
||||
const error = buildError(message, 502);
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.showAlertForError(error);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toast-message')).toHaveTextContent('The server is down');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a 404 status code', () => {
|
||||
it('renders the correct message', async() => {
|
||||
const error = buildError('', 404);
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.showAlertForError(error);
|
||||
});
|
||||
|
||||
expect(screen.queryAllByTestId('toast')).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a 410 status code', () => {
|
||||
it('renders the correct message', async() => {
|
||||
const error = buildError('', 410);
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.showAlertForError(error);
|
||||
});
|
||||
|
||||
expect(screen.queryAllByTestId('toast')).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an accepted status code', () => {
|
||||
describe('with a message from the server', () => {
|
||||
it('renders the correct message', async() => {
|
||||
const message = 'custom message';
|
||||
const error = buildError(message, 200);
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.showAlertForError(error);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toast-message')).toHaveTextContent(message);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a message from the server', () => {
|
||||
it('renders the correct message', async() => {
|
||||
const message = 'The request has been accepted for processing';
|
||||
const error = buildError(message, 202);
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.showAlertForError(error);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toast-message')).toHaveTextContent(message);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a response', () => {
|
||||
it('renders the default message', async() => {
|
||||
const error = new AxiosError();
|
||||
const { toast } = renderApp();
|
||||
|
||||
act(() => {
|
||||
toast.showAlertForError(error);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('toast')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('toast-message')).toHaveTextContent('An unexpected error occurred.');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,6 +4,7 @@ import { render, RenderOptions } from '@testing-library/react';
|
|||
import { renderHook, RenderHookOptions } from '@testing-library/react-hooks';
|
||||
import { merge } from 'immutable';
|
||||
import React, { FC, ReactElement } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
@ -60,6 +61,7 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
|
|||
{children}
|
||||
|
||||
<NotificationsContainer />
|
||||
<Toaster />
|
||||
</IntlProvider>
|
||||
</ChatProvider>
|
||||
</QueryClientProvider>
|
||||
|
|
207
app/soapbox/toast.tsx
Normal file
207
app/soapbox/toast.tsx
Normal file
|
@ -0,0 +1,207 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
import toast, { Toast } from 'react-hot-toast';
|
||||
import { defineMessages, FormattedMessage, MessageDescriptor } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Icon } from './components/ui';
|
||||
import { httpErrorMessages } from './utils/errors';
|
||||
|
||||
type ToastText = string | MessageDescriptor
|
||||
type ToastType = 'success' | 'error' | 'info'
|
||||
|
||||
interface IToastOptions {
|
||||
action?(): void
|
||||
actionLink?: string
|
||||
actionLabel?: ToastText
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 4000;
|
||||
|
||||
const renderText = (text: ToastText) => {
|
||||
if (typeof text === 'string') {
|
||||
return text;
|
||||
} else {
|
||||
return <FormattedMessage {...text} />;
|
||||
}
|
||||
};
|
||||
|
||||
const buildToast = (t: Toast, message: ToastText, type: ToastType, opts: Omit<IToastOptions, 'duration'> = {}) => {
|
||||
const { action, actionLabel, actionLink } = opts;
|
||||
|
||||
const dismissToast = () => toast.dismiss(t.id);
|
||||
|
||||
const renderIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return (
|
||||
<Icon
|
||||
src={require('@tabler/icons/checks.svg')}
|
||||
className='w-6 h-6 text-success-500'
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
case 'info':
|
||||
return (
|
||||
<Icon
|
||||
src={require('@tabler/icons/info-circle.svg')}
|
||||
className='w-6 h-6 text-primary-600 dark:text-accent-blue'
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Icon
|
||||
src={require('@tabler/icons/info-circle.svg')}
|
||||
className='w-6 h-6 text-danger-600'
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderAction = () => {
|
||||
const classNames = 'ml-3 mt-0.5 flex-shrink-0 rounded-full text-sm font-medium text-primary-600 dark:text-accent-blue hover:underline focus:outline-none';
|
||||
|
||||
if (action && actionLabel) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames}
|
||||
onClick={() => {
|
||||
dismissToast();
|
||||
action();
|
||||
}}
|
||||
data-testid='toast-action'
|
||||
>
|
||||
{renderText(actionLabel)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (actionLink && actionLabel) {
|
||||
return (
|
||||
<Link
|
||||
to={actionLink}
|
||||
onClick={dismissToast}
|
||||
className={classNames}
|
||||
data-testid='toast-action-link'
|
||||
>
|
||||
{renderText(actionLabel)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid='toast'
|
||||
className={
|
||||
classNames({
|
||||
'pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white dark:bg-gray-900 shadow-lg dark:ring-2 dark:ring-gray-800': true,
|
||||
'animate-enter': t.visible,
|
||||
'animate-leave': !t.visible,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className='p-4'>
|
||||
<div className='flex items-start'>
|
||||
<div className='flex w-0 flex-1 justify-between items-start'>
|
||||
<div className='w-0 flex-1 flex items-start'>
|
||||
<div className='flex-shrink-0'>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
|
||||
<p className='ml-3 pt-0.5 text-sm font-medium text-gray-900 dark:text-gray-100' data-testid='toast-message'>
|
||||
{renderText(message)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{renderAction()}
|
||||
</div>
|
||||
|
||||
{/* Dismiss Button */}
|
||||
<div className='ml-4 flex flex-shrink-0 pt-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex rounded-md text-gray-400 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
||||
onClick={dismissToast}
|
||||
data-testid='toast-dismiss'
|
||||
>
|
||||
<span className='sr-only'>Close</span>
|
||||
<Icon src={require('@tabler/icons/x.svg')} className='w-5 h-5' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const createToast = (type: ToastType, message: ToastText, opts?: IToastOptions) => {
|
||||
const duration = opts?.duration || DEFAULT_DURATION;
|
||||
|
||||
toast.custom((t) => buildToast(t, message, type, opts), {
|
||||
duration,
|
||||
});
|
||||
};
|
||||
|
||||
function info(message: ToastText, opts?: IToastOptions) {
|
||||
createToast('info', message, opts);
|
||||
}
|
||||
|
||||
function success(message: ToastText, opts?: IToastOptions) {
|
||||
createToast('success', message, opts);
|
||||
}
|
||||
|
||||
function error(message: ToastText, opts?: IToastOptions) {
|
||||
createToast('error', message, opts);
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
});
|
||||
|
||||
function showAlertForError(networkError: AxiosError<any>) {
|
||||
if (networkError?.response) {
|
||||
const { data, status, statusText } = networkError.response;
|
||||
|
||||
if (status === 502) {
|
||||
return error('The server is down');
|
||||
}
|
||||
|
||||
if (status === 404 || status === 410) {
|
||||
// Skip these errors as they are reflected in the UI
|
||||
return null;
|
||||
}
|
||||
|
||||
let message: string | undefined = statusText;
|
||||
|
||||
if (data?.error) {
|
||||
message = data.error;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = httpErrorMessages.find((httpError) => httpError.code === status)?.description;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
return error(message);
|
||||
}
|
||||
} else {
|
||||
console.error(networkError);
|
||||
return error(messages.unexpectedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
info,
|
||||
success,
|
||||
error,
|
||||
showAlertForError,
|
||||
};
|
|
@ -164,6 +164,7 @@
|
|||
"react-datepicker": "^4.8.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-hotkeys": "^1.1.4",
|
||||
"react-immutable-proptypes": "^2.2.0",
|
||||
"react-immutable-pure-component": "^2.2.2",
|
||||
|
|
|
@ -65,6 +65,8 @@ module.exports = {
|
|||
'sonar-scale-3': 'sonar-scale-3 3s 0.5s linear infinite',
|
||||
'sonar-scale-2': 'sonar-scale-2 3s 1s linear infinite',
|
||||
'sonar-scale-1': 'sonar-scale-1 3s 1.5s linear infinite',
|
||||
'enter': 'enter 200ms ease-out',
|
||||
'leave': 'leave 150ms ease-in forwards',
|
||||
},
|
||||
keyframes: {
|
||||
'sonar-scale-4': {
|
||||
|
@ -83,6 +85,14 @@ module.exports = {
|
|||
from: { opacity: '0.4', transform: 'scale(1)' },
|
||||
to: { opacity: '0', transform: 'scale(2.5)' },
|
||||
},
|
||||
enter: {
|
||||
from: { transform: 'scale(0.9)', opacity: '0' },
|
||||
to: { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
leave: {
|
||||
from: { transform: 'scale(1)', opacity: '1' },
|
||||
to: { transform: 'scale(0.9)', opacity: '0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -6357,6 +6357,11 @@ gonzales-pe@^4.3.0:
|
|||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
|
||||
goober@^2.1.10:
|
||||
version "2.1.11"
|
||||
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.11.tgz#bbd71f90d2df725397340f808dbe7acc3118e610"
|
||||
integrity sha512-5SS2lmxbhqH0u9ABEWq7WPU69a4i2pYcHeCxqaNq6Cw3mnrF0ghWNM4tEGid4dKy8XNIAUbuThuozDHHKJVh3A==
|
||||
|
||||
graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6:
|
||||
version "4.2.8"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
|
||||
|
@ -9859,6 +9864,13 @@ react-helmet@^6.1.0:
|
|||
react-fast-compare "^3.1.1"
|
||||
react-side-effect "^2.1.0"
|
||||
|
||||
react-hot-toast@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.0.tgz#b91e7a4c1b6e3068fc599d3d83b4fb48668ae51d"
|
||||
integrity sha512-qnnVbXropKuwUpriVVosgo8QrB+IaPJCpL8oBI6Ov84uvHZ5QQcTp2qg6ku2wNfgJl6rlQXJIQU5q+5lmPOutA==
|
||||
dependencies:
|
||||
goober "^2.1.10"
|
||||
|
||||
react-hotkeys@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-1.1.4.tgz#a0712aa2e0c03a759fd7885808598497a4dace72"
|
||||
|
|
Loading…
Reference in a new issue