Skip to content

Commit

Permalink
refactor(Toaster): fix incompatibility of different Toaster APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
icekimi23 committed Dec 13, 2024
1 parent 8772d1a commit e788ad6
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 278 deletions.
111 changes: 19 additions & 92 deletions src/components/Toaster/Provider/ToasterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,105 +2,32 @@

import React from 'react';

import type {InternalToastProps, ToastProps, ToasterPublicMethods} from '../types';
import {getToastIndex} from '../utilities/getToastIndex';
import {hasToast} from '../utilities/hasToast';
import {removeToast} from '../utilities/removeToast';
import type {ToasterSingleton} from '../ToasterSingleton';
import type {InternalToastProps} from '../types';

import {ToasterContext} from './ToasterContext';
import {ToastsContext} from './ToastsContext';

type Props = React.PropsWithChildren<{}>;
type Props = React.PropsWithChildren<{
toaster: ToasterSingleton;
}>;

export const ToasterProvider = React.forwardRef<ToasterPublicMethods, Props>(
function ToasterProvider({children}: Props, ref) {
const [toasts, setToasts] = React.useState<InternalToastProps[]>([]);
export const ToasterProvider = ({toaster, children}: Props) => {
const [toasts, setToasts] = React.useState<InternalToastProps[]>([]);

const add = React.useCallback((toast: ToastProps) => {
const {name} = toast;
React.useEffect(() => {
const unsubscribe = toaster.subscribe(setToasts);

setToasts((toasts) => {
let nextToasts = toasts;
return () => {
unsubscribe();
};
}, [toaster]);

if (hasToast(toasts, name)) {
nextToasts = removeToast(toasts, name);
}

return [
...nextToasts,
{
...toast,
addedAt: Date.now(),
ref: React.createRef<HTMLDivElement>(),
},
];
});
}, []);

const remove = React.useCallback((toastName: ToastProps['name']) => {
setToasts((toasts) => {
return removeToast(toasts, toastName);
});
}, []);

const removeAll = React.useCallback(() => {
setToasts(() => []);
}, []);

const update = React.useCallback(
(toastName: ToastProps['name'], override: Partial<ToastProps>) => {
setToasts((toasts) => {
if (!hasToast(toasts, toastName)) {
return toasts;
}

const index = getToastIndex(toasts, toastName);

return [
...toasts.slice(0, index),
{
...toasts[index],
...override,
},
...toasts.slice(index + 1),
];
});
},
[],
);

const toastsRef = React.useRef<InternalToastProps[]>(toasts);
React.useEffect(() => {
toastsRef.current = toasts;
}, [toasts]);
const has = React.useCallback((toastName: ToastProps['name']) => {
return toastsRef.current ? hasToast(toastsRef.current, toastName) : false;
}, []);

const toasterContext = React.useMemo(() => {
return {
add,
remove,
removeAll,
update,
has,
};
}, [add, remove, removeAll, update, has]);

React.useImperativeHandle(ref, () => ({
add,
remove,
removeAll,
update,
has,
}));

return (
<ToasterContext.Provider value={toasterContext}>
<ToastsContext.Provider value={toasts}>{children}</ToastsContext.Provider>
</ToasterContext.Provider>
);
},
);
return (
<ToasterContext.Provider value={toaster}>
<ToastsContext.Provider value={toasts}>{children}</ToastsContext.Provider>
</ToasterContext.Provider>
);
};

ToasterProvider.displayName = 'ToasterProvider';
31 changes: 5 additions & 26 deletions src/components/Toaster/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ Component for adjustable notifications.
```jsx
import React from 'react';
import ReactDOMClient from 'react-dom/client';
import {ToasterComponent, ToasterProvider} from '@gravity-ui/uikit';
import {Toaster, ToasterComponent, ToasterProvider} from '@gravity-ui/uikit';

const toaster = new Toaster();

const root = ReactDOMClient.createRoot(document.getElementById('root'));
root.render(
<ToasterProvider>
<ToasterProvider toaster={toaster}>
<App />
<ToasterComponent className="optional additional classes" />
</ToasterProvider>,
Expand Down Expand Up @@ -66,8 +68,6 @@ const FoobarWithToaster = withToaster()(FoobarComponent);
Toaster has singleton, so when it is initialized in different parts of the application, the same instance will be returned.
On initialization, it is possible to transmit a className that will be assigned to dom-element which wrap all toasts.

### React < 18

```js
import {Toaster} from '@gravity-ui/uikit';
const toaster = new Toaster();
Expand All @@ -79,34 +79,13 @@ or
import {toaster} from '@gravity-ui/uikit/toaster-singleton';
```

### React 18

```js
import ReactDOMClient from 'react-dom/client';
import {Toaster} from '@gravity-ui/uikit';
Toaster.injectReactDOMClient(ReactDOMClient);
const toaster = new Toaster();
```

or

```js
import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18';
```

## Constructor arguments

| Parameter | Type | Default | Description |
| :-------- | :-------- | :---------- | :-------------------------------------------------- |
| className | `string` | `undefined` | Custom class name to add to the component container |
| mobile | `boolean` | `false` | Configuration that manages mobile/desktop views |

## Methods

| Method name | Params | Description |
| :---------------------------- | :----------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- |
| add(toastOptions) | `Object` | Creates a new notification |
| remove(name) | `string` | Manually deletes an existing notification |
| removeAll() | | Deletes all existing notifications |
| update(name, overrideOptions) | `string`, `Object` | Changes already rendered notification content. In `overrideOptions`, the following fields are optional: `title`, `type`, `content`, `actions` |
| has(name) | `string` | Checks fora toast with the given name in the list of displayed toasts |

Expand Down
143 changes: 63 additions & 80 deletions src/components/Toaster/ToasterSingleton.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
'use client';

import React from 'react';

import get from 'lodash/get';
import ReactDOM from 'react-dom';

import {block} from '../utils/cn';

import {ToasterProvider} from './Provider/ToasterProvider';
import {ToasterComponent} from './ToasterComponent/ToasterComponent';
import type {ToastProps, ToasterArgs, ToasterPublicMethods} from './types';
import type {InternalToastProps, ToastProps} from './types';
import {getToastIndex} from './utilities/getToastIndex';
import {hasToast} from './utilities/hasToast';
import {removeToast} from './utilities/removeToast';

const TOASTER_KEY: unique symbol = Symbol('Toaster instance key');
const bToaster = block('toaster');
let ReactDOMClient: any;

declare global {
interface Window {
Expand All @@ -22,95 +14,86 @@ declare global {
}

export class ToasterSingleton {
static injectReactDOMClient(client: any) {
ReactDOMClient = client;
}
private toasts: InternalToastProps[] = [];
private listeners: ((toasts: InternalToastProps[]) => void)[] = [];

private rootNode!: HTMLDivElement;
private reactRoot!: any;
private className = '';
private mobile = false;
private componentAPI: null | ToasterPublicMethods = null;
constructor() {
if (window[TOASTER_KEY] instanceof ToasterSingleton) {
return window[TOASTER_KEY];
}

constructor(args?: ToasterArgs) {
const className = get(args, ['className'], '');
const mobile = get(args, ['mobile'], false);
window[TOASTER_KEY] = this;
}

if (window[TOASTER_KEY] instanceof ToasterSingleton) {
const me = window[TOASTER_KEY];
me.className = className;
me.mobile = mobile;
me.setRootNodeClassName();
return me;
add(toast: ToastProps) {
let nextToasts = this.toasts;

if (hasToast(nextToasts, toast.name)) {
nextToasts = removeToast(nextToasts, toast.name);
}

this.className = className;
this.mobile = mobile;
this.createRootNode();
this.createReactRoot();
this.render();
this.toasts = [
...nextToasts,
{
...toast,
addedAt: Date.now(),
ref: {current: null},
},
];

window[TOASTER_KEY] = this;
this.notify();
}

destroy() {
// eslint-disable-next-line react/no-deprecated
ReactDOM.unmountComponentAtNode(this.rootNode);
document.body.removeChild(this.rootNode);
remove(name: string) {
this.toasts = removeToast(this.toasts, name);

this.notify();
}

add = (options: ToastProps) => {
this.componentAPI?.add(options);
};
removeAll() {
this.toasts = [];

remove = (name: string) => {
this.componentAPI?.remove(name);
};
this.notify();
}

removeAll = () => {
this.componentAPI?.removeAll();
};
update(name: string, overrideOptions: Partial<ToastProps>) {
if (!hasToast(this.toasts, name)) {
return;
}

update = (name: string, overrideOptions: Partial<ToastProps>) => {
this.componentAPI?.update(name, overrideOptions);
};
const index = getToastIndex(this.toasts, name);

has = (name: string) => {
return this.componentAPI?.has(name) ?? false;
};
this.toasts = [
...this.toasts.slice(0, index),
{
...this.toasts[index],
...overrideOptions,
},
...this.toasts.slice(index + 1),
];

private createRootNode() {
this.rootNode = document.createElement('div');
this.setRootNodeClassName();
document.body.appendChild(this.rootNode);
this.notify();
}

private createReactRoot() {
if (ReactDOMClient) {
this.reactRoot = ReactDOMClient.createRoot(this.rootNode);
}
has(name: string) {
return hasToast(this.toasts, name);
}

private render() {
const container = (
<ToasterProvider
ref={(api) => {
this.componentAPI = api;
}}
>
<ToasterComponent hasPortal={false} mobile={this.mobile} />
</ToasterProvider>
);

if (this.reactRoot) {
this.reactRoot.render(container);
} else {
// eslint-disable-next-line react/no-deprecated
ReactDOM.render(container, this.rootNode, () => Promise.resolve());
subscribe(listener: (toasts: InternalToastProps[]) => void) {
if (typeof listener === 'function') {
this.listeners.push(listener);
}

return () => {
this.listeners = this.listeners.filter(
(currentListener) => listener !== currentListener,
);
};
}

private setRootNodeClassName() {
this.rootNode.className = bToaster({mobile: this.mobile}, this.className);
private notify() {
for (const listener of this.listeners) {
listener(this.toasts);
}
}
}
5 changes: 4 additions & 1 deletion src/components/Toaster/__stories__/Toaster.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {BUTTON_VIEWS} from '../../Button/constants';
import {ToasterProvider} from '../Provider/ToasterProvider';
import {Toast} from '../Toast/Toast';
import {ToasterComponent} from '../ToasterComponent/ToasterComponent';
import {ToasterSingleton} from '../ToasterSingleton';
import {TOAST_THEMES} from '../constants';
import {useToaster} from '../hooks/useToaster';
import type {ToastAction} from '../types';
Expand Down Expand Up @@ -53,13 +54,15 @@ function booleanControl(label: string) {
};
}

const toasterInstance = new ToasterSingleton();

export default {
title: 'Components/Feedback/Toaster',
component: Toast,
decorators: [
function withToasters(Story) {
return (
<ToasterProvider>
<ToasterProvider toaster={toasterInstance}>
<Story />
</ToasterProvider>
);
Expand Down
Loading

0 comments on commit e788ad6

Please sign in to comment.