Skip to content

Commit

Permalink
Toaster component (#86)
Browse files Browse the repository at this point in the history
* Add toaster component WIP

* Update jest and testing-library. Tests for toaster

* Update toaster readme

* remove dangling TODO from toaster

* Add better responsiveness for Toaster

* Restrict Toaster types. Bypass click events for ToasterContainer

* Update changelog. Add notice that we are using an external lib

* Remove unused typing file

* Spread rest on Toaster component to auto-pause timer

* Update toaster behaviour to match specs

* Supress warnings for logs. Fix toaster example setup

* less jarring height transition

* fixing text alignment

* Set minHeight for container at 60px

* Test out Percy with animations

Co-authored-by: maartenafink <[email protected]>
Co-authored-by: Guillaume Lambert <[email protected]>
  • Loading branch information
3 people authored Jun 12, 2020
1 parent 00153d0 commit 8fad2d0
Show file tree
Hide file tree
Showing 18 changed files with 2,328 additions and 592 deletions.
2 changes: 1 addition & 1 deletion jest.setup.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable import/no-extraneous-dependencies */
import 'jest-dom/extend-expect';
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import * as emotion from 'emotion';
import { createSerializer, matchers as emotionMatchers } from 'jest-emotion';
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@
"@storybook/addon-options": "^5.2.8",
"@storybook/addons": "^5.2.8",
"@storybook/react": "^5.2.8",
"@testing-library/react": "^8.0.1",
"@testing-library/react": "^10.0.5",
"@testing-library/jest-dom": "^5.9.0",
"@types/classnames": "^2.2.7",
"@types/jest": "^24.0.15",
"@types/jest": "^25.2.3",
"@types/lodash": "^4.14.123",
"@types/luxon": "^1.15.2",
"@types/react": "^16.8.23",
Expand Down Expand Up @@ -100,8 +101,7 @@
"htmltojsx": "^0.3.0",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^24.5.0",
"jest-dom": "^3.0.0",
"jest": "^26.0.1",
"jest-emotion": "^10.0.0",
"json-loader": "^0.5.7",
"lerna": "^3.10.2",
Expand Down
1 change: 1 addition & 0 deletions packages/flame/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Refer to the [CONTRIBUTING guide](https://github.com/lightspeed/flame/blob/maste

- New AlertInCard component ([#82](https://github.com/lightspeed/flame/pull/82))
- Alert component will now automatically inject the right icons as per DSD specs ([#82](https://github.com/lightspeed/flame/pull/82))
- New Toaster component ([#86](https://github.com/lightspeed/flame/pull/86))

### Fixed

Expand Down
2 changes: 2 additions & 0 deletions packages/flame/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@
"dependencies": {
"@styled-system/css": "^5.1.5",
"@styled-system/theme-get": "5.0.16",
"@types/react-toast-notifications": "^2.4.0",
"@types/styled-system": "5.1.6",
"polished": "^2.3.0",
"popper.js": "^1.15.0",
"react-modal": "^3.5.1",
"react-select": "^2.0.0",
"react-toast-notifications": "^2.4.0",
"styled-system": "5.1.4",
"type-fest": "^0.3.0"
},
Expand Down
4 changes: 2 additions & 2 deletions packages/flame/src/Dialog/Dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe('<Dialog />', () => {
const { getByRole } = customRender(
<Dialog title={title} message={message} isOpen onCancel={onCancel} onConfirm={onConfirm} />,
);
const modalEl = getByRole('dialog');
const modalEl = getByRole('dialog', { hidden: true });
const modalStyles = window.getComputedStyle(modalEl);

expect(modalStyles.getPropertyValue('max-width')).toEqual('500px');
Expand All @@ -124,7 +124,7 @@ describe('<Dialog />', () => {
it('should allow to override the defaults', () => {
const maxWidth = '6969px';
const { getByRole } = customRender(<DialogWithSize maximumWidth={maxWidth} />);
const modalEl = getByRole('dialog');
const modalEl = getByRole('dialog', { hidden: true });
const modalStyles = window.getComputedStyle(modalEl);

expect(modalStyles.getPropertyValue('max-width')).toEqual(maxWidth);
Expand Down
109 changes: 109 additions & 0 deletions packages/flame/src/Toaster/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Toaster

Toaster inform users on the outcome of an action. They appear temporarily, towards the bottom of the screen. They shouldn’t interrupt the user experience, and they don’t require user input to disappear.

This component uses the [react-toast-notifications](https://github.com/jossmac/react-toast-notifications) library.

## Usage

First, wrap your application with the `<ToasterProvider>` component.

```jsx
// App.js
import React from 'react';
import { FlameTheme, FlameGlobalStyles } from '@lightspeed/flame/Core';
import { ToasterProvider } from '@lightspeed/flame/Toaster';

const App = () => (
<FlameTheme>
<FlameGlobalStyles />
<ToasterProvider>
<div>{/* The rest of your app */}</div>
</ToasterProvider>
</FlameTheme>
);
```

Once that is done, you may use the provided hooks to generate a toast notification.

```jsx
// MyComponent.js
import * as React from 'react';
import { useToast } from '@lightspeed/flame/Toaster';

const MyComponent = () => {
const { addToast } = useToasts();
return (
<button
type="button"
onClick={() =>
addToast('This is a toast', {
appearance: 'success', // set to 'error' for a red error toast
autoDismiss: false, // set to true to have a timer that automatically closes it
})
}
>
Create toast
</button>
);
};
```

## Components

### `<ToasterProvider />`

A pre-configured `ToastProvider` from the [react-toast-notifications](https://github.com/jossmac/react-toast-notifications) library.

Please consult its documentation for a full list of all the props available.

### `useToast()`

An augmented hook of the original `useToast` found in [react-toast-notifications](https://github.com/jossmac/react-toast-notifications).

The `useToast` hook has the following signature:

```jsx
const {
addToast,
addActionableToast,
removeToast,
removeAllToasts,
updateToast,
toastStack,
} = useToasts();
```

The `addToast` method has three arguments:

1. The first is the content of the toast, which can be any renderable `Node`.
1. The second is the `Options` object, which can take any shape you like. `Options.appearance` is required when using the `DefaultToast`. When departing from the default shape, you must provide an alternative, compliant `Toast` component.
1. The third is an optional callback, which is passed the added toast `ID`.

The `addActionableToast` method has three arguments:

1. The first is the `ActionableContent` object, which requires 3 properties to be filled. `ActionableContent.content` is the content of the toast, which can be any renderable `Node`. `ActionableContent.actionTitle` is the string that'll be shown for the action button. `ActionableContent.actionCallback` is the function that will be executed when the action button is clicked.
1. The second is the `Options` object, which can take any shape you like. `Options.appearance` is required when using the `DefaultToast`. When departing from the default shape, you must provide an alternative, compliant `Toast` component.
1. The third is an optional callback, which is passed the added toast `ID`.

The `removeToast` method has two arguments:

1. The first is the `ID` of the toast to remove.
1. The second is an optional callback.

The `removeAllToasts` method has no arguments.

The `updateToast` method has three arguments:

1. The first is the `ID` of the toast to update.
1. The second is the `Options` object, which differs slightly from the add method because it accepts a `content` property.
1. The third is an optional callback, which is passed the updated toast `ID`.

The `toastStack` is an array of objects representing the current toasts, e.g.

```jsx
[
{ content: 'Something went wrong', id: 'generated-string', appearance: 'error' },
{ content: 'Item saved', id: 'generated-string', appearance: 'success' },
];
```
114 changes: 114 additions & 0 deletions packages/flame/src/Toaster/Toaster.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as React from 'react';
import { customRender, screen, fireEvent, waitFor } from 'test-utils';
import { ToasterProvider, useToasts } from './index';

const TestAddToast: React.FC<{ appearance: 'success' | 'error'; autoDismiss: boolean }> = ({
appearance,
autoDismiss,
}) => {
const { addToast } = useToasts();
return (
<button
type="button"
onClick={() => {
addToast('this is a message', {
appearance,
autoDismiss,
});
}}
>
create toast
</button>
);
};

const TestAddActionableToast: React.FC<{ actionCallback: () => void }> = ({ actionCallback }) => {
const { addActionableToast } = useToasts();
return (
<button
type="button"
onClick={() => {
addActionableToast({
content: 'actionable toast',
actionTitle: 'action-title',
actionCallback,
});
}}
>
create toast
</button>
);
};

describe('Toaster', () => {
describe('without auto-dismss set to false', () => {
it('should render out a toaster', async () => {
customRender(
<ToasterProvider>
<TestAddToast appearance="success" autoDismiss={false} />
</ToasterProvider>,
);

const toaster = await screen.queryByRole('alert');
expect(toaster).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('alert')).toHaveTextContent(/this is a message/);
fireEvent.click(screen.getByLabelText('Dismiss toast'));

await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});

it('should render out a success toaster with a custom action', async () => {
const spy = jest.fn();
customRender(
<ToasterProvider>
<TestAddActionableToast actionCallback={spy} />
</ToasterProvider>,
);

const toaster = await screen.queryByRole('alert');
expect(toaster).not.toBeInTheDocument();

fireEvent.click(screen.getByText('create toast'));
expect(screen.getByRole('alert')).toHaveTextContent(/actionable toast/);

fireEvent.click(screen.getByLabelText('action-title'));
expect(spy).toHaveBeenCalledTimes(1);

fireEvent.click(screen.getByLabelText('Dismiss toast'));

await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
});

// We have fake timers running here, isolate these test-cases in their own
// describe block
describe('with auto-dismiss set to true', () => {
it('should render out a success toaster that disappears after a while', async () => {
jest.useFakeTimers();

customRender(
<ToasterProvider>
<TestAddToast appearance="success" autoDismiss={true} />
</ToasterProvider>,
);

const toaster = await screen.queryByRole('alert');
expect(toaster).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('alert')).toHaveTextContent(/this is a message/);

jest.runAllTimers();

await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
});
});
Loading

0 comments on commit 8fad2d0

Please sign in to comment.