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

refactor(Toaster): fix incompatibility of different Toaster APIs #1987

Open
wants to merge 1 commit into
base: main
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
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;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is definitely breaking change, please change target branch to next

}>;

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();
}
Copy link
Contributor

@ogonkov ogonkov Dec 17, 2024

Choose a reason for hiding this comment

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

I think we need to maintain destroy() method, let it call removeAll underneath


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);
}
}
Comment on lines +94 to 98
Copy link
Contributor

Choose a reason for hiding this comment

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

Please separate subscribe pattern from Toaster class to separate base class EventEmitter

}
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
Loading