Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Toaster component #86

Merged
merged 18 commits into from
Jun 12, 2020
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -17,6 +17,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