Skip to content

Support multiple nested Contexts #115

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
49 changes: 42 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ const FormWithToasts = () => {
} else {
addToast('Saved Successfully', { appearance: 'success' })
}
}
};

return <form onSubmit={onSubmit}>...</form>
}
};

const App = () => (
<ToastProvider>
Expand All @@ -50,19 +50,20 @@ For brevity:
- `TransitionState` is equal to `'entering' | 'entered' | 'exiting' | 'exited'`.

| Property | Description |
| -------------------------------------- | ---------------------------------------------------------------------------------------- |
| --------------------------- | ---------------------------------------------------------------------------------------- |
| autoDismissTimeout `number` | Default `5000`. The time until a toast will be dismissed automatically, in milliseconds. |
| autoDismiss `boolean` | Default: `false`. Whether or not to dismiss the toast automatically after a timeout. |

| children `Node` | Required. Your app content. |
| components `{ ToastContainer, Toast }` | Replace the underlying components. |
| placement `PlacementType` | Default `top-right`. Where, in relation to the viewport, to place the toasts. |
| transitionDuration `number` | Default `220`. The duration of the CSS transition on the `Toast` component. |
| name `string` | Default `default`. Provide a unique name when using [Nested Providers](#nested-providers). |

## Toast Props

| Property | Description |
| ---------------------------------- | ------------------------------------------------------------------ |
| ---------------------------------- | --------------------------------------------------------------------- |
| appearance | Required. One of `success`, `error`, `warning`, `info` |
| children | Required. The content of the toast notification. |
| autoDismiss `boolean` | Inherited from `ToastProvider` if not provided. |
Expand All @@ -77,9 +78,15 @@ For brevity:
The `useToast` hook has the following signature:

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

`options` passed to `useToasts` are:

| Option | Description |
| ------------- | ------------------------------------------------------------------------------------------ |
| name `string` | Default `default`. Provide a unique name when using [Nested Providers](#nested-providers). |

The `addToast` method has three arguments:

1. The first is the content of the toast, which can be any renderable `Node`.
Expand All @@ -104,8 +111,8 @@ 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' }
]
{ content: 'Item saved', id: 'generated-string', appearance: 'success' },
];
```

## Replaceable Components
Expand Down Expand Up @@ -137,6 +144,34 @@ export const MyCustomToast = ({ children, ...props }) => (
);
```

## Nested Providers

Displaying individual toasts differently is done using nested `<ToastProvider>`s.

For example, [the docs
page](https://jossmac.github.io/react-toast-notifications) displays both
"notification" style & "snack bar" style toasts simultaneously on the same page.

Nested Providers must be given a unique `name` prop, which is then also passed
to the `<ToastConsumer>` component or `useToasts()` hook.

```jsx
import { ToastProvider, ToastConsumer } from 'react-toast-notifications';

const App = () => (
<ToastProvider>
<ToastProvider name="snack">
<ToastConsumer>
{({ add }) => <button onClick={() => add('A toast')}>Add Toast</button>}
</ToastConsumer>
<ToastConsumer name="snack">
{({ add }) => <button onClick={() => add('A snack')}>Add Snack</button>}
</ToastConsumer>
</ToastProvider>
</ToastProvider>
);
```

## Alternatives

This library may not meet your needs. Here are some alternative I came across whilst searching for this solution:
Expand Down
28 changes: 16 additions & 12 deletions examples/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,13 @@ function App() {

return (
<ToastProvider>
<ToastProvider
name="snack"
autoDismiss
autoDismissTimeout={6000}
components={{ Toast: Snack }}
placement="bottom-center"
>
<ConnectivityListener />
<Section area="intro">
<Container>
Expand Down Expand Up @@ -245,12 +252,6 @@ function App() {
==============================
*/}
<Section area="config">
<ToastProvider
autoDismiss
autoDismissTimeout={6000}
components={{ Toast: Snack }}
placement="bottom-center"
>
<Container>
<Body>
<StretchGroup reverse>
Expand All @@ -260,7 +261,7 @@ function App() {
<p>
Replace or configure any part of the notification system.
</p>
<ToastConsumer>
<ToastConsumer name="snack">
{({ add, toasts }) => (
<Button
appearance="snack"
Expand Down Expand Up @@ -292,7 +293,6 @@ const App = () => (
</StretchGroup>
</Body>
</Container>
</ToastProvider>
</Section>
{/*
==============================
Expand All @@ -311,12 +311,15 @@ const App = () => (
haphazardly, from some random buttons in your app.*
</p>
<p>
To see an example of how you might use this IRL, toggle the{' '}
<code>Offline</code> checkbox in the Network pane of your
dev tools. If you're on mobile, just turn on flight-mode.
To see an example of how you might use this IRL, toggle
the <code>Offline</code> checkbox in the Network pane of
your dev tools. If you're on mobile, just turn on
flight-mode.
</p>
<p>
<small>* It's totally cool if you are, no judgement.</small>
<small>
* It's totally cool if you are, no judgement.
</small>
</p>
</div>
</ContentBlock>
Expand All @@ -326,6 +329,7 @@ const App = () => (
</Container>
</Section>
</ToastProvider>
</ToastProvider>
);
}

Expand Down
65 changes: 48 additions & 17 deletions src/ToastProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { ToastContainer, type ToastContainerProps } from './ToastContainer';
import { type ToastProps, DefaultToast } from './ToastElement';
const defaultComponents = { Toast: DefaultToast, ToastContainer };

import { generateUEID, NOOP } from './utils';
import { generateUEID, NOOP, omit } from './utils';
import type {
AddFn,
UpdateFn,
Expand All @@ -28,9 +28,9 @@ import type {
Id,
} from './types';

// $FlowFixMe `createContext`
const ToastContext = React.createContext();
const { Consumer, Provider } = ToastContext;
const contexts = {};

const DEFAULT_CONTEXT_NAME = 'default';

const canUseDOM = !!(
typeof window !== 'undefined' &&
Expand Down Expand Up @@ -60,8 +60,12 @@ type Props = {
// A convenience prop; the duration of the toast transition, in milliseconds.
// Note that specifying this will override any defaults set on individual children Toasts.
transitionDuration: number,
name: string,
};
type State = {
toasts: ToastsType,
context: Object,
};
type State = { toasts: ToastsType };
type Context = {
add: AddFn,
remove: RemoveFn,
Expand All @@ -72,19 +76,30 @@ type Context = {

export class ToastProvider extends Component<Props, State> {
static defaultProps = {
name: DEFAULT_CONTEXT_NAME,
autoDismiss: false,
autoDismissTimeout: 5000,
components: defaultComponents,
placement: 'top-right',
transitionDuration: 220,
};

state = { toasts: [] };
constructor(props: Props) {
super(props);

contexts[props.name] = contexts[props.name] || React.createContext();
const context = contexts[props.name];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only create a new context if there isn't already one for that name.

Then we continue using the created context within this component for its lifetime.


this.state = {
toasts: [],
context,
};
}

// Internal Helpers
// ------------------------------

has = (id) => {
has = (id: Id) => {
if (!this.state.toasts.length) {
return false;
}
Expand All @@ -100,7 +115,7 @@ export class ToastProvider extends Component<Props, State> {
// ------------------------------

add = (content: Node, options?: Options = {}, cb: Callback = NOOP) => {
const id = options.id || generateUEID();
const id: Id = options.id || generateUEID();
const callback = () => cb(id);

// bail if a toast exists with this ID
Expand All @@ -110,7 +125,7 @@ export class ToastProvider extends Component<Props, State> {

// update the toast stack
this.setState(state => {
const newToast = { content, id, ...options };
const newToast = { content, id, ...omit(options, 'id') };
const toasts = [...state.toasts, newToast];

return { toasts };
Expand Down Expand Up @@ -151,7 +166,7 @@ export class ToastProvider extends Component<Props, State> {
this.setState(state => {
const old = state.toasts;
const i = old.findIndex(t => t.id === id);
const updatedToast = { ...old[i], ...options };
const updatedToast = { ...old[i], ...omit(options, 'id') };
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These id changes fix some flow errors I was getting.

const toasts = [ ...old.slice(0, i), updatedToast, ...old.slice(i + 1)];

return { toasts };
Expand All @@ -173,6 +188,7 @@ export class ToastProvider extends Component<Props, State> {

const hasToasts = Boolean(toasts.length);
const portalTarget = canUseDOM ? document.body : null; // appease flow
const { Provider } = this.state.context;

return (
<Provider value={{ add, remove, removeAll, update, toasts }}>
Expand Down Expand Up @@ -229,20 +245,35 @@ export class ToastProvider extends Component<Props, State> {
}
}

export const ToastConsumer = ({ children }: { children: Context => Node }) => (
<Consumer>{context => children(context)}</Consumer>
);
export const ToastConsumer = ({
name = DEFAULT_CONTEXT_NAME,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each of the 3 ways of consuming toast context (<ToastConsumer>, withToastManager(), and useToasts()) now all accept an optional name prop.

That name is used to lookup a context created by a provider.

If the named context isn't known, then an error is thrown. Otherwise, that named context is the one used for the life of the component.

In this way, any deeply nested consumers can connect to any parent provider no matter how many they are as long as they have unique names.

children,
}: {
name: string,
children: Context => Node,
}) => {
const context = contexts[name];
if (!context) {
throw Error('The `ToastConsumer` component must be nested as a descendent of the `ToastProvider`.');
}
const { Consumer } = context;
return <Consumer>{ctx => children(ctx)}</Consumer>;
};

export const withToastManager = (Comp: ComponentType<*>) =>
export const withToastManager = (
Comp: ComponentType<*>,
{ name = DEFAULT_CONTEXT_NAME }: { name: string } = {}
) =>
// $FlowFixMe `forwardRef`
React.forwardRef((props: *, ref: Ref<*>) => (
<ToastConsumer>
<ToastConsumer name={name}>
{context => <Comp toastManager={context} {...props} ref={ref} />}
</ToastConsumer>
));

export const useToasts = () => {
const ctx = useContext(ToastContext);
export const useToasts = ({ name = DEFAULT_CONTEXT_NAME }: { name: string } = {}) => {
const context = contexts[name] || {};
const ctx = useContext(context);

if (!ctx) {
throw Error('The `useToasts` hook must be called from a descendent of the `ToastProvider`.');
Expand Down
3 changes: 2 additions & 1 deletion src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type AppearanceTypes = 'error' | 'info' | 'success' | 'warning';
export type Id = string;
export type Callback = Id => void;
export type Options = {
id?: Id,
appearance: AppearanceTypes,
autoDismiss?: boolean,
onDismiss?: Callback,
Expand All @@ -25,5 +26,5 @@ export type Placement =
| 'top-center'
| 'top-right';

export type ToastType = Options & { appearance: AppearanceTypes, content: Node, id: Id };
export type ToastType = { content: Node, id: Id } & Options;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes a flow issue I was having where it thought id was optional on internal state.

export type ToastsType = Array<ToastType>;
8 changes: 8 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,11 @@ export function generateUEID() {
second = ('000' + second.toString(36)).slice(-3);
return first + second;
}
export function omit(obj, keyToOmit) {
return Object.entries(obj).reduce((memo, [key, value]) => {
if (key !== keyToOmit) {
memo[key] = value;
}
return memo;
}, {});
}