Skip to content

Commit 878323f

Browse files
authored
Merge pull request marmelab#10614 from marmelab/custom-notifications
Introduce `useCloseNotification` hook
2 parents e5d2c71 + 66e9f26 commit 878323f

7 files changed

Lines changed: 200 additions & 66 deletions

File tree

docs/useNotify.md

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,6 @@ const NotifyButton = () => {
2323
};
2424
```
2525

26-
The hook takes no argument and returns a callback. The callback takes 2 arguments:
27-
28-
- The message to display (a string, or a React node)
29-
- an `options` object with the following keys:
30-
- `type`: The notification type (`info`, `success`, `error` or `warning` - the default is `info`)
31-
- `messageArgs`: options to pass to the `translate` function (because notification messages are translated if your admin has an `i18nProvider`). It is useful for inserting variables into the translation.
32-
- `undoable`: Set it to `true` if the notification should contain an "undo" button
33-
- `autoHideDuration`: Duration (in milliseconds) after which the notification hides. Set it to `null` if the notification should not be dismissible.
34-
- `multiLine`: Set it to `true` if the notification message should be shown in more than one line.
35-
- `anchorOrigin`: The position of the notification. The default is `{ vertical: 'top', horizontal: 'right' }`. See [the Material UI documentation](https://mui.com/material-ui/react-snackbar/) for more details.
3626

3727
Here are more examples of `notify` calls:
3828

@@ -47,6 +37,26 @@ notify('item.created', { type: 'info', messageArgs: { resource: 'post' } });
4737
notify('Element updated', { type: 'info', undoable: true });
4838
```
4939

40+
## Parameters
41+
42+
The hook takes no argument and returns a callback. The callback takes 2 arguments:
43+
44+
| Name | Required | Type | Default | Description |
45+
| --- | --- | --- | --- | --- |
46+
| `message` | Required | `string` | - | The message to display (a string, or a React node) |
47+
| `options` | | `object` | - | The options |
48+
49+
The `options` is an object that can have the following properties:
50+
51+
| Name | Type | Default | Description |
52+
| --- | --- | --- | --- |
53+
| `anchorOrigin` | `object` | - | The position of the notification. The default is `{ vertical: 'bottom', horizontal: 'center' }`. See [the Material UI documentation](https://mui.com/material-ui/react-snackbar/) for more details. |
54+
| `autoHideDuration` | `number | null` | `4000` | Duration (in milliseconds) after which the notification hides. Set it to `null` if the notification should not be dismissible. |
55+
| `messageArgs` | `object` | - | options to pass to the `translate` function (because notification messages are translated if your admin has an `i18nProvider`). It is useful for inserting variables into the translation. |
56+
| `multiLine` | `boolean` | - | Set it to `true` if the notification message should be shown in more than one line. |
57+
| `undoable` | `boolean` | - | Set it to `true` if the notification should contain an "undo" button |
58+
| `type` | `string` | `info` | The notification type (`info`, `success`, `error` or `warning` - the default is `info`) |
59+
5060
## `anchorOrigin`
5161

5262
You can change the default position of the notification by passing an `anchorOrigin` option. The value is passed to [the Material UI `<Snackbar anchorOrigin>`](https://mui.com/material-ui/react-snackbar/) prop.
@@ -180,7 +190,7 @@ This allows e.g. using [Material UI's `<Alert>` component](https://mui.com/mater
180190

181191
![useNotify with node](./img/use-notify-node.png)
182192

183-
```jsx
193+
```tsx
184194
import { useSubscribe } from "@react-admin/ra-realtime";
185195
import { useNotify, useDataProvider } from "react-admin";
186196
import { Alert } from "@mui/material";
@@ -216,4 +226,48 @@ export const ConnectionWatcher = () => {
216226
};
217227
```
218228

219-
Note that if you use this ability to pass a React node, the message will not be translated - you'll have to translate it yourself using [`useTranslate`](./useTranslate.md).
229+
Note that if you use this ability to pass a React node, the message will not be translated - you'll have to translate it yourself using [`useTranslate`](./useTranslate.md).
230+
231+
## Closing The Notification
232+
233+
If you have custom actions in your notification element, you can leverage the `useCloseNotification` hook to close the notification programmatically:
234+
235+
```tsx
236+
import { useFormContext } from 'react-hook-form';
237+
import { Button, useCloseNotification, useNotify } from 'react-admin';
238+
import { SnackbarContent } from '@mui/material';
239+
240+
const SetFormValueButton = () => {
241+
const { setValue } = useFormContext();
242+
const notify = useNotify();
243+
244+
return (
245+
<Button
246+
onClick={() => {
247+
setValue('myfield', 'a value');
248+
notify(<SetFormValueNotification reset={() => setValue('myfield', '')} />);
249+
}}
250+
label="Set myfield value"
251+
/>
252+
);
253+
};
254+
255+
const SetFormValueNotification = ({ reset }: { reset:() => void }) => {
256+
const closeNotification = useCloseNotification();
257+
258+
return (
259+
<SnackbarContent
260+
message="myfield changed"
261+
action={
262+
<Button
263+
onClick={() => {
264+
reset();
265+
closeNotification();
266+
}}
267+
label="Reset"
268+
/>
269+
}
270+
/>
271+
);
272+
};
273+
```
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createContext } from 'react';
2+
3+
export type CloseNotificationContextValue = () => void;
4+
5+
export const CloseNotificationContext =
6+
createContext<CloseNotificationContextValue | null>(null);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
export * from './AddNotificationContext';
2+
export * from './CloseNotificationContext';
23
export * from './NotificationContext';
34
export * from './NotificationContextProvider';
45
export * from './types';
56
export * from './useAddNotificationContext';
7+
export * from './useCloseNotification';
68
export * from './useNotificationContext';
79
export * from './useNotify';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { useContext } from 'react';
2+
import { CloseNotificationContext } from './CloseNotificationContext';
3+
4+
export const useCloseNotification = () => {
5+
const closeNotification = useContext(CloseNotificationContext);
6+
if (!closeNotification) {
7+
throw new Error(
8+
'useCloseNotification must be used within a CloseNotificationContext.Provider'
9+
);
10+
}
11+
return closeNotification;
12+
};

packages/ra-ui-materialui/src/layout/Notification.spec.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as React from 'react';
2-
import { render, screen } from '@testing-library/react';
2+
import { render, screen, waitFor } from '@testing-library/react';
33

4-
import { ConsecutiveUndoable } from './Notification.stories';
4+
import {
5+
ConsecutiveUndoable,
6+
CustomNotificationWithAction,
7+
} from './Notification.stories';
58

69
describe('<Notification />', () => {
710
it('should confirm the first undoable notification when a second one starts', async () => {
@@ -30,4 +33,14 @@ describe('<Notification />', () => {
3033
// the second delete hasn't been called
3134
expect(deleteOne).toHaveBeenCalledTimes(1);
3235
});
36+
it('allows custom notifications to close themselves', async () => {
37+
const consoleLog = jest.spyOn(console, 'log').mockImplementation();
38+
render(<CustomNotificationWithAction />);
39+
await screen.findByText('Applied automatic changes');
40+
screen.getByText('Cancel').click();
41+
await waitFor(() => {
42+
expect(screen.queryByText('Applied automatic changes')).toBeNull();
43+
});
44+
expect(consoleLog).toHaveBeenCalledWith('Custom action');
45+
});
3346
});

packages/ra-ui-materialui/src/layout/Notification.stories.tsx

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import * as React from 'react';
22
import {
3-
useNotify,
3+
CoreAdminContext,
44
NotificationContextProvider,
55
useDelete,
6-
CoreAdminContext,
6+
useNotify,
7+
useCloseNotification,
78
} from 'ra-core';
8-
import { Alert, Button, Stack } from '@mui/material';
9+
import {
10+
Alert,
11+
Button,
12+
SnackbarContent,
13+
SnackbarContentProps,
14+
Stack,
15+
} from '@mui/material';
916

1017
import { Notification } from './Notification';
1118

@@ -202,3 +209,37 @@ export const ConsecutiveUndoable = ({
202209
<Notification />
203210
</CoreAdminContext>
204211
);
212+
213+
// forwardRef is required for Snackbar to work (transitions) in React 18
214+
const CustomNotificationWithActionContent = React.forwardRef<
215+
HTMLDivElement,
216+
SnackbarContentProps
217+
>((props, ref) => {
218+
const closeNotification = useCloseNotification();
219+
const handleClick = () => {
220+
console.log('Custom action');
221+
closeNotification();
222+
};
223+
return (
224+
<SnackbarContent
225+
message="Applied automatic changes"
226+
action={<Button onClick={handleClick}>Cancel</Button>}
227+
{...props}
228+
ref={ref}
229+
/>
230+
);
231+
});
232+
233+
const CustomNotificationElementWithAction = () => {
234+
const notify = useNotify();
235+
React.useEffect(() => {
236+
notify(<CustomNotificationWithActionContent />);
237+
}, [notify]);
238+
return null;
239+
};
240+
241+
export const CustomNotificationWithAction = () => (
242+
<Wrapper>
243+
<CustomNotificationElementWithAction />
244+
</Wrapper>
245+
);

packages/ra-ui-materialui/src/layout/Notification.tsx

Lines changed: 55 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { Button, Snackbar, SnackbarProps, SnackbarOrigin } from '@mui/material';
55
import clsx from 'clsx';
66

77
import {
8-
useNotificationContext,
9-
undoableEventEmitter,
10-
useTranslate,
8+
CloseNotificationContext,
119
NotificationPayload,
10+
undoableEventEmitter,
11+
useNotificationContext,
1212
useTakeUndoableMutation,
13+
useTranslate,
1314
} from 'ra-core';
1415

1516
const defaultAnchorOrigin: SnackbarOrigin = {
@@ -120,52 +121,57 @@ export const Notification = (props: NotificationProps) => {
120121
} = notificationOptions || {};
121122

122123
return (
123-
<StyledSnackbar
124-
className={className}
125-
open={open}
126-
message={
127-
message &&
128-
typeof message === 'string' &&
129-
translate(message, messageArgs)
130-
}
131-
autoHideDuration={
132-
// Only apply the default autoHideDuration when autoHideDurationFromMessage is undefined
133-
// as 0 and null are valid values
134-
autoHideDurationFromMessage === undefined
135-
? autoHideDuration
136-
: autoHideDurationFromMessage ?? undefined
137-
}
138-
disableWindowBlurListener={undoable}
139-
TransitionProps={{ onExited: handleExited }}
140-
onClose={handleRequestClose}
141-
ContentProps={{
142-
className: clsx(NotificationClasses[typeFromMessage || type], {
143-
[NotificationClasses.multiLine]:
144-
multilineFromMessage || multiLine,
145-
}),
146-
}}
147-
action={
148-
undoable ? (
149-
<Button
150-
color="primary"
151-
className={NotificationClasses.undo}
152-
size="small"
153-
onClick={handleUndo}
154-
>
155-
<>{translate('ra.action.undo')}</>
156-
</Button>
157-
) : null
158-
}
159-
anchorOrigin={anchorOrigin}
160-
{...rest}
161-
{...options}
162-
>
163-
{message &&
164-
typeof message !== 'string' &&
165-
React.isValidElement(message)
166-
? message
167-
: undefined}
168-
</StyledSnackbar>
124+
<CloseNotificationContext.Provider value={handleRequestClose}>
125+
<StyledSnackbar
126+
className={className}
127+
open={open}
128+
message={
129+
message &&
130+
typeof message === 'string' &&
131+
translate(message, messageArgs)
132+
}
133+
autoHideDuration={
134+
// Only apply the default autoHideDuration when autoHideDurationFromMessage is undefined
135+
// as 0 and null are valid values
136+
autoHideDurationFromMessage === undefined
137+
? autoHideDuration
138+
: autoHideDurationFromMessage ?? undefined
139+
}
140+
disableWindowBlurListener={undoable}
141+
TransitionProps={{ onExited: handleExited }}
142+
onClose={handleRequestClose}
143+
ContentProps={{
144+
className: clsx(
145+
NotificationClasses[typeFromMessage || type],
146+
{
147+
[NotificationClasses.multiLine]:
148+
multilineFromMessage || multiLine,
149+
}
150+
),
151+
}}
152+
action={
153+
undoable ? (
154+
<Button
155+
color="primary"
156+
className={NotificationClasses.undo}
157+
size="small"
158+
onClick={handleUndo}
159+
>
160+
<>{translate('ra.action.undo')}</>
161+
</Button>
162+
) : null
163+
}
164+
anchorOrigin={anchorOrigin}
165+
{...rest}
166+
{...options}
167+
>
168+
{message &&
169+
typeof message !== 'string' &&
170+
React.isValidElement(message)
171+
? message
172+
: undefined}
173+
</StyledSnackbar>
174+
</CloseNotificationContext.Provider>
169175
);
170176
};
171177

0 commit comments

Comments
 (0)