diff --git a/app/soapbox/__tests__/toast.test.tsx b/app/soapbox/__tests__/toast.test.tsx new file mode 100644 index 000000000..eeb3b926b --- /dev/null +++ b/app/soapbox/__tests__/toast.test.tsx @@ -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( + + , + , + ), + }; +} + +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(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); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 986828826..f827b1b17 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -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 = ({ children, storeProps, routerProps = {} }) => { {children} + diff --git a/app/soapbox/toast.tsx b/app/soapbox/toast.tsx new file mode 100644 index 000000000..3d5a18bce --- /dev/null +++ b/app/soapbox/toast.tsx @@ -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 ; + } +}; + +const buildToast = (t: Toast, message: ToastText, type: ToastType, opts: Omit = {}) => { + const { action, actionLabel, actionLink } = opts; + + const dismissToast = () => toast.dismiss(t.id); + + const renderIcon = () => { + switch (type) { + case 'success': + return ( + + ); + case 'info': + return ( + + ); + case 'error': + return ( + + ); + } + }; + + 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 ( + + ); + } + + if (actionLink && actionLabel) { + return ( + + {renderText(actionLabel)} + + ); + } + + return null; + }; + + return ( +
+
+
+
+
+
+ {renderIcon()} +
+ +

+ {renderText(message)} +

+
+ + {/* Action */} + {renderAction()} +
+ + {/* Dismiss Button */} +
+ +
+
+
+
+ ); +}; + +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) { + 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, +}; \ No newline at end of file diff --git a/package.json b/package.json index 14ed5d916..c75d0b651 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tailwind.config.js b/tailwind.config.js index c57acc16e..2f7b72fb2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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' }, + }, }, }, }, diff --git a/yarn.lock b/yarn.lock index 10d918d46..a922ee9d8 100644 --- a/yarn.lock +++ b/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"