From c71b852bf7c24243e80995447698e16e013217b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Thu, 13 Jan 2022 22:17:20 +0100 Subject: [PATCH 01/10] [BC Break] Use context to store notifications instead of Redux --- packages/ra-core/src/actions/index.ts | 1 - .../src/actions/notificationActions.ts | 67 ------- .../ra-core/src/auth/Authenticated.spec.tsx | 27 ++- .../src/auth/useAuthenticated.spec.tsx | 32 ++-- packages/ra-core/src/auth/useCheckAuth.ts | 2 +- packages/ra-core/src/auth/useLogin.ts | 13 +- .../src/auth/useLogoutIfAccessDenied.ts | 2 +- .../button/useDeleteWithConfirmController.tsx | 2 +- .../button/useDeleteWithUndoController.tsx | 2 +- .../create/useCreateController.spec.tsx | 91 ++++----- .../controller/create/useCreateController.ts | 7 +- .../edit/useEditController.spec.tsx | 111 ++++++----- .../src/controller/edit/useEditController.ts | 8 +- .../field/useReferenceArrayFieldController.ts | 2 +- .../field/useReferenceManyFieldController.ts | 2 +- .../field/useReferenceOneFieldController.tsx | 2 +- .../src/controller/list/useListController.ts | 2 +- .../src/controller/show/useShowController.ts | 3 +- .../ra-core/src/core/CoreAdminContext.tsx | 30 +-- packages/ra-core/src/core/CoreAdminUI.tsx | 1 + .../src/dataProvider/combineDataProviders.ts | 2 +- .../src/dataProvider/defaultDataProvider.ts | 2 +- .../src/dataProvider/useDataProvider.ts | 2 +- packages/ra-core/src/i18n/useSetLocale.tsx | 2 +- packages/ra-core/src/index.ts | 3 +- .../src/notification/NotificationContext.ts | 44 +++++ .../NotificationContextProvider.tsx | 38 ++++ packages/ra-core/src/notification/index.ts | 5 + packages/ra-core/src/notification/types.ts | 18 ++ .../notification/useNotificationContext.ts | 4 + .../src/notification/useNotify.spec.tsx | 99 ++++++++++ .../ra-core/src/notification/useNotify.ts | 40 ++++ packages/ra-core/src/reducer/admin/index.ts | 2 - .../src/reducer/admin/notifications.ts | 46 ----- packages/ra-core/src/reducer/index.ts | 2 - packages/ra-core/src/sideEffect/index.ts | 3 +- .../ra-core/src/sideEffect/useNotify.spec.tsx | 75 -------- packages/ra-core/src/sideEffect/useNotify.ts | 64 ------- packages/ra-test/src/TestContext.spec.tsx | 42 ----- packages/ra-test/src/TestContext.tsx | 1 - .../src/AdminContext.tsx | 7 +- packages/ra-ui-materialui/src/AdminUI.tsx | 26 +++ packages/ra-ui-materialui/src/auth/Login.tsx | 5 - .../src/button/SaveButton.spec.tsx | 172 ++++++++---------- packages/ra-ui-materialui/src/index.ts | 2 + .../ra-ui-materialui/src/layout/Layout.tsx | 70 +++---- .../src/layout/Notification.tsx | 52 +++--- packages/react-admin/src/Admin.tsx | 8 +- packages/react-admin/src/AdminUI.tsx | 19 -- packages/react-admin/src/index.ts | 2 - 50 files changed, 618 insertions(+), 646 deletions(-) delete mode 100644 packages/ra-core/src/actions/notificationActions.ts create mode 100644 packages/ra-core/src/notification/NotificationContext.ts create mode 100644 packages/ra-core/src/notification/NotificationContextProvider.tsx create mode 100644 packages/ra-core/src/notification/index.ts create mode 100644 packages/ra-core/src/notification/types.ts create mode 100644 packages/ra-core/src/notification/useNotificationContext.ts create mode 100644 packages/ra-core/src/notification/useNotify.spec.tsx create mode 100644 packages/ra-core/src/notification/useNotify.ts delete mode 100644 packages/ra-core/src/reducer/admin/notifications.ts delete mode 100644 packages/ra-core/src/sideEffect/useNotify.spec.tsx delete mode 100644 packages/ra-core/src/sideEffect/useNotify.ts rename packages/{react-admin => ra-ui-materialui}/src/AdminContext.tsx (66%) create mode 100644 packages/ra-ui-materialui/src/AdminUI.tsx delete mode 100644 packages/react-admin/src/AdminUI.tsx diff --git a/packages/ra-core/src/actions/index.ts b/packages/ra-core/src/actions/index.ts index b2698b56bd0..952bca897c4 100644 --- a/packages/ra-core/src/actions/index.ts +++ b/packages/ra-core/src/actions/index.ts @@ -1,6 +1,5 @@ export * from './clearActions'; export * from './filterActions'; export * from './listActions'; -export * from './notificationActions'; export * from './uiActions'; export * from './undoActions'; diff --git a/packages/ra-core/src/actions/notificationActions.ts b/packages/ra-core/src/actions/notificationActions.ts deleted file mode 100644 index 52bd6e8ad84..00000000000 --- a/packages/ra-core/src/actions/notificationActions.ts +++ /dev/null @@ -1,67 +0,0 @@ -export const SHOW_NOTIFICATION = 'RA/SHOW_NOTIFICATION'; - -export type NotificationType = 'success' | 'info' | 'warning' | 'error'; - -export interface NotificationOptions { - // The duration in milliseconds the notification is shown - autoHideDuration?: number; - // Arguments used to translate the message - messageArgs?: any; - // If true, the notification shows the message in multiple lines - multiLine?: boolean; - // If true, the notification shows an Undo button - undoable?: boolean; -} - -export interface NotificationPayload { - readonly message: string; - readonly type: NotificationType; - readonly notificationOptions?: NotificationOptions; -} - -export interface ShowNotificationAction { - readonly type: typeof SHOW_NOTIFICATION; - readonly payload: NotificationPayload; -} - -/** - * Shows a snackbar/toast notification on the screen - * - * @see {@link https://material-ui.com/api/snackbar/|Material ui snackbar component} - * @see {@link https://material.io/guidelines/components/snackbars-toasts.html|Material ui reference document on snackbar} - */ -export const showNotification = ( - // A translatable label or text to display on notification - message: string, - // The type of the notification - type: NotificationType = 'info', - // Specify additional parameters of notification - notificationOptions?: NotificationOptions -): ShowNotificationAction => ({ - type: SHOW_NOTIFICATION, - payload: { - ...notificationOptions, - type, - message, - }, -}); - -export const HIDE_NOTIFICATION = 'RA/HIDE_NOTIFICATION'; - -export interface HideNotificationAction { - readonly type: typeof HIDE_NOTIFICATION; -} - -export const hideNotification = (): HideNotificationAction => ({ - type: HIDE_NOTIFICATION, -}); - -export const RESET_NOTIFICATION = 'RA/RESET_NOTIFICATION'; - -export interface ResetNotificationAction { - readonly type: typeof RESET_NOTIFICATION; -} - -export const resetNotification = (): ResetNotificationAction => ({ - type: RESET_NOTIFICATION, -}); diff --git a/packages/ra-core/src/auth/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index 7a7e0bf9349..8be9382fe44 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -6,7 +6,7 @@ import { Provider } from 'react-redux'; import { createMemoryHistory } from 'history'; import { Routes, Route, useLocation } from 'react-router-dom'; import { CoreAdminContext, createAdminStore } from '../core'; -import { showNotification } from '../actions'; +import { useNotificationContext } from '../notification'; import Authenticated from './Authenticated'; import { testDataProvider } from '../dataProvider'; @@ -63,6 +63,16 @@ describe('', () => { ); }; + + let notificationsSpy; + const Notification = () => { + const { notifications } = useNotificationContext(); + React.useEffect(() => { + notificationsSpy = notifications; + }, [notifications]); + return null; + }; + render( ', () => { dataProvider={testDataProvider()} history={history} > + ', () => { await waitFor(() => { expect(authProvider.checkAuth.mock.calls[0][0]).toEqual({}); expect(authProvider.logout.mock.calls[0][0]).toEqual({}); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch.mock.calls[0][0]).toEqual( - showNotification('ra.auth.auth_check_error', 'warning') - ); - expect(dispatch.mock.calls[1][0]).toEqual({ + expect(dispatch).toHaveBeenCalledTimes(1); + expect(notificationsSpy).toEqual([ + { + message: 'ra.auth.auth_check_error', + type: 'warning', + notificationOptions: {}, + }, + ]); + expect(dispatch.mock.calls[0][0]).toEqual({ type: 'RA/CLEAR_STATE', }); expect(screen.getByLabelText('nextPathname').innerHTML).toEqual( diff --git a/packages/ra-core/src/auth/useAuthenticated.spec.tsx b/packages/ra-core/src/auth/useAuthenticated.spec.tsx index d73a18d0216..a1249e1923d 100644 --- a/packages/ra-core/src/auth/useAuthenticated.spec.tsx +++ b/packages/ra-core/src/auth/useAuthenticated.spec.tsx @@ -5,7 +5,7 @@ import { Provider } from 'react-redux'; import { createMemoryHistory } from 'history'; import { Routes, Route, useLocation } from 'react-router-dom'; import Authenticated from './Authenticated'; -import { showNotification } from '../actions'; +import { useNotificationContext } from '../notification'; import { CoreAdminContext, createAdminStore } from '../core'; import { testDataProvider } from '../dataProvider'; @@ -120,13 +120,19 @@ describe('useAuthenticated', () => { ); }; + let notificationsSpy; + const Notification = () => { + const { notifications } = useNotificationContext(); + React.useEffect(() => { + notificationsSpy = notifications; + }, [notifications]); + return null; + }; + render( - + + { await waitFor(() => { expect(authProvider.checkAuth.mock.calls[0][0]).toEqual({}); expect(authProvider.logout.mock.calls[0][0]).toEqual({}); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch.mock.calls[0][0]).toEqual( - showNotification('ra.auth.auth_check_error', 'warning') - ); - expect(dispatch.mock.calls[1][0]).toEqual({ + expect(dispatch).toHaveBeenCalledTimes(1); + expect(notificationsSpy).toEqual([ + { + message: 'ra.auth.auth_check_error', + type: 'warning', + notificationOptions: {}, + }, + ]); + expect(dispatch.mock.calls[0][0]).toEqual({ type: 'RA/CLEAR_STATE', }); expect(screen.getByLabelText('nextPathname').innerHTML).toEqual( diff --git a/packages/ra-core/src/auth/useCheckAuth.ts b/packages/ra-core/src/auth/useCheckAuth.ts index 4f8b5319a8e..ed7608bee1d 100644 --- a/packages/ra-core/src/auth/useCheckAuth.ts +++ b/packages/ra-core/src/auth/useCheckAuth.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; import useLogout from './useLogout'; -import useNotify from '../sideEffect/useNotify'; +import { useNotify } from '../notification'; /** * Get a callback for calling the authProvider.checkAuth() method. diff --git a/packages/ra-core/src/auth/useLogin.ts b/packages/ra-core/src/auth/useLogin.ts index 08f0990eee9..0a651bf9fa8 100644 --- a/packages/ra-core/src/auth/useLogin.ts +++ b/packages/ra-core/src/auth/useLogin.ts @@ -1,9 +1,8 @@ import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useDispatch } from 'react-redux'; +import { useNotificationContext } from '../notification'; import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; -import { resetNotification } from '../actions/notificationActions'; /** * Get a callback for calling the authProvider.login() method @@ -33,14 +32,14 @@ const useLogin = (): Login => { const location = useLocation(); const locationState = location.state as any; const navigate = useNavigate(); - const dispatch = useDispatch(); + const { resetNotifications } = useNotificationContext(); const nextPathName = locationState && locationState.nextPathname; const nextSearch = locationState && locationState.nextSearch; const login = useCallback( (params: any = {}, pathName) => authProvider.login(params).then(ret => { - dispatch(resetNotification()); + resetNotifications(); const redirectUrl = pathName ? pathName : nextPathName + nextSearch || @@ -48,16 +47,16 @@ const useLogin = (): Login => { navigate(redirectUrl); return ret; }), - [authProvider, navigate, nextPathName, nextSearch, dispatch] + [authProvider, navigate, nextPathName, nextSearch, resetNotifications] ); const loginWithoutProvider = useCallback( (_, __) => { - dispatch(resetNotification()); + resetNotifications(); navigate(defaultAuthParams.afterLoginUrl); return Promise.resolve(); }, - [navigate, dispatch] + [navigate, resetNotifications] ); return authProvider ? login : loginWithoutProvider; diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts index 44d58420620..d2ab9a30350 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import useAuthProvider from './useAuthProvider'; import useLogout from './useLogout'; -import { useNotify } from '../sideEffect'; +import { useNotify } from '../notification'; import { useNavigate } from 'react-router'; let timer; diff --git a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx index 845f9bbb055..58504d0ca41 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithConfirmController.tsx @@ -8,11 +8,11 @@ import { UseMutationOptions } from 'react-query'; import { useDelete } from '../../dataProvider'; import { - useNotify, useRedirect, useUnselect, RedirectionSideEffect, } from '../../sideEffect'; +import { useNotify } from '../../notification'; import { Record, MutationMode, DeleteParams } from '../../types'; import { useResourceContext } from '../../core'; diff --git a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx index 6e68cc312b0..c195b6501c0 100644 --- a/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteWithUndoController.tsx @@ -3,11 +3,11 @@ import { UseMutationOptions } from 'react-query'; import { useDelete } from '../../dataProvider'; import { - useNotify, useRedirect, useUnselect, RedirectionSideEffect, } from '../../sideEffect'; +import { useNotify } from '../../notification'; import { Record, DeleteParams } from '../../types'; import { useResourceContext } from '../../core'; diff --git a/packages/ra-core/src/controller/create/useCreateController.spec.tsx b/packages/ra-core/src/controller/create/useCreateController.spec.tsx index ad6e2bbde7a..7205ea34a64 100644 --- a/packages/ra-core/src/controller/create/useCreateController.spec.tsx +++ b/packages/ra-core/src/controller/create/useCreateController.spec.tsx @@ -7,6 +7,7 @@ import { Provider } from 'react-redux'; import { getRecordFromLocation } from './useCreateController'; import { CreateController } from './CreateController'; import { testDataProvider } from '../../dataProvider'; +import { useNotificationContext } from '../../notification'; import { CoreAdminContext, createAdminStore } from '../../core'; describe('useCreateController', () => { @@ -91,32 +92,35 @@ describe('useCreateController', () => { create: (_, { data }) => Promise.resolve({ data: { id: 123, ...data } }), }); - const store = createAdminStore(); - const dispatch = jest.spyOn(store, 'dispatch'); + + let notificationsSpy; + const Notification = () => { + const { notifications } = useNotificationContext(); + React.useEffect(() => { + notificationsSpy = notifications; + }, [notifications]); + return null; + }; + render( - - - - {({ save }) => { - saveCallback = save; - return null; - }} - - - + + + + {({ save }) => { + saveCallback = save; + return null; + }} + + ); await act(async () => saveCallback({ foo: 'bar' })); - const notify = dispatch.mock.calls.find( - params => params[0].type === 'RA/SHOW_NOTIFICATION' - ); - expect(notify[0]).toEqual({ - type: 'RA/SHOW_NOTIFICATION', - payload: { - messageArgs: { smart_count: 1 }, - type: 'info', + expect(notificationsSpy).toEqual([ + { message: 'ra.notification.created', + type: 'info', + notificationOptions: { messageArgs: { smart_count: 1 } }, }, - }); + ]); }); it('should execute default failure side effects on failure', async () => { @@ -126,32 +130,35 @@ describe('useCreateController', () => { getOne: () => Promise.resolve({ data: { id: 12 } } as any), create: () => Promise.reject({ message: 'not good' }), }); - const store = createAdminStore(); - const dispatch = jest.spyOn(store, 'dispatch'); + + let notificationsSpy; + const Notification = () => { + const { notifications } = useNotificationContext(); + React.useEffect(() => { + notificationsSpy = notifications; + }, [notifications]); + return null; + }; + render( - - - - {({ save }) => { - saveCallback = save; - return null; - }} - - - + + + + {({ save }) => { + saveCallback = save; + return null; + }} + + ); await act(async () => saveCallback({ foo: 'bar' })); - const notify = dispatch.mock.calls.find( - params => params[0].type === 'RA/SHOW_NOTIFICATION' - ); - expect(notify[0]).toEqual({ - type: 'RA/SHOW_NOTIFICATION', - payload: { - messageArgs: { _: 'not good' }, - type: 'warning', + expect(notificationsSpy).toEqual([ + { message: 'not good', + type: 'warning', + notificationOptions: { messageArgs: { _: 'not good' } }, }, - }); + ]); }); it('should allow mutationOptions to override the default success side effects', async () => { diff --git a/packages/ra-core/src/controller/create/useCreateController.ts b/packages/ra-core/src/controller/create/useCreateController.ts index 7de8fe4c86e..9d7b8ac0808 100644 --- a/packages/ra-core/src/controller/create/useCreateController.ts +++ b/packages/ra-core/src/controller/create/useCreateController.ts @@ -7,11 +7,8 @@ import { UseMutationOptions } from 'react-query'; import { useAuthenticated } from '../../auth'; import { useCreate } from '../../dataProvider'; -import { - useNotify, - useRedirect, - RedirectionSideEffect, -} from '../../sideEffect'; +import { useRedirect, RedirectionSideEffect } from '../../sideEffect'; +import { useNotify } from '../../notification'; import { SetOnSuccess, SetOnFailure, diff --git a/packages/ra-core/src/controller/edit/useEditController.spec.tsx b/packages/ra-core/src/controller/edit/useEditController.spec.tsx index 80a16da9333..115c9908f3b 100644 --- a/packages/ra-core/src/controller/edit/useEditController.spec.tsx +++ b/packages/ra-core/src/controller/edit/useEditController.spec.tsx @@ -8,6 +8,7 @@ import { Provider } from 'react-redux'; import { EditController } from './EditController'; import { DataProvider } from '../../types'; import { CoreAdminContext, createAdminStore } from '../../core'; +import { useNotificationContext } from '../../notification'; import { SaveContextProvider } from '..'; import undoableEventEmitter from '../../dataProvider/undoableEventEmitter'; @@ -230,32 +231,46 @@ describe('useEditController', () => { update: (_, { id, data, previousData }) => Promise.resolve({ data: { id, ...previousData, ...data } }), } as unknown) as DataProvider; - const store = createAdminStore(); - const dispatch = jest.spyOn(store, 'dispatch'); + + let notificationsSpy; + const Notification = () => { + const { notifications } = useNotificationContext(); + React.useEffect(() => { + notificationsSpy = notifications; + }, [notifications]); + return null; + }; + render( - - - - - {({ save }) => { - saveCallback = save; - return null; - }} - - - - + + + + + {({ save }) => { + saveCallback = save; + return null; + }} + + + ); await act(async () => saveCallback({ foo: 'bar' })); await new Promise(resolve => setTimeout(resolve, 10)); - expect(dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'RA/SHOW_NOTIFICATION', - }) - ); + expect(notificationsSpy).toEqual([ + { + message: 'ra.notification.updated', + type: 'info', + notificationOptions: { + messageArgs: { + smart_count: 1, + }, + undoable: false, + }, + }, + ]); }); it('should allow mutationOptions to override the default success side effects in pessimistic mode', async () => { @@ -421,35 +436,41 @@ describe('useEditController', () => { getOne: () => Promise.resolve({ data: { id: 12 } }), update: () => Promise.reject({ message: 'not good' }), } as unknown) as DataProvider; - const store = createAdminStore(); - const dispatch = jest.spyOn(store, 'dispatch'); + + let notificationsSpy; + const Notification = () => { + const { notifications } = useNotificationContext(); + React.useEffect(() => { + notificationsSpy = notifications; + }, [notifications]); + return null; + }; + render( - - - - - {({ save }) => { - saveCallback = save; - return null; - }} - - - - + + + + + {({ save }) => { + saveCallback = save; + return null; + }} + + + ); await act(async () => saveCallback({ foo: 'bar' })); await new Promise(resolve => setTimeout(resolve, 10)); - expect(dispatch).toHaveBeenCalledWith({ - type: 'RA/SHOW_NOTIFICATION', - payload: { - type: 'warning', + expect(notificationsSpy).toEqual([ + { message: 'not good', - messageArgs: { _: 'not good' }, + type: 'warning', + notificationOptions: { messageArgs: { _: 'not good' } }, }, - }); + ]); }); it('should allow mutationOptions to override the default failure side effects in pessimistic mode', async () => { diff --git a/packages/ra-core/src/controller/edit/useEditController.ts b/packages/ra-core/src/controller/edit/useEditController.ts index 24817f57b23..d38b77f8be8 100644 --- a/packages/ra-core/src/controller/edit/useEditController.ts +++ b/packages/ra-core/src/controller/edit/useEditController.ts @@ -10,11 +10,9 @@ import { OnFailure, UpdateParams, } from '../../types'; -import { - useNotify, - useRedirect, - RedirectionSideEffect, -} from '../../sideEffect'; +import { useRedirect, RedirectionSideEffect } from '../../sideEffect'; +import { useNotify } from '../../notification'; + import { useGetOne, useUpdate, diff --git a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts index fb70704237c..a9d40480d68 100644 --- a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts @@ -3,7 +3,7 @@ import get from 'lodash/get'; import { Record, SortPayload } from '../../types'; import { useGetManyAggregate } from '../../dataProvider'; import { ListControllerResult, useList } from '../list'; -import { useNotify } from '../../sideEffect'; +import { useNotify } from '../../notification'; export interface UseReferenceArrayFieldControllerParams { filter?: any; diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts index 45e2dc9be10..1b583ec3e42 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts @@ -4,7 +4,7 @@ import isEqual from 'lodash/isEqual'; import { useSafeSetState, removeEmpty } from '../../util'; import { useGetManyReference } from '../../dataProvider'; -import { useNotify } from '../../sideEffect'; +import { useNotify } from '../../notification'; import { Record, SortPayload } from '../../types'; import { ListControllerResult } from '../list'; import usePaginationState from '../usePaginationState'; diff --git a/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx b/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx index 7190adc3eef..715df1ccafd 100644 --- a/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx +++ b/packages/ra-core/src/controller/field/useReferenceOneFieldController.tsx @@ -1,7 +1,7 @@ import get from 'lodash/get'; import { useGetManyReference } from '../../dataProvider'; -import { useNotify } from '../../sideEffect'; +import { useNotify } from '../../notification'; import { Record } from '../../types'; import { UseReferenceResult } from '../useReference'; diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index c97fb6dad56..879adf62410 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -3,7 +3,7 @@ import { UseQueryOptions } from 'react-query'; import { useAuthenticated } from '../../auth'; import { useTranslate } from '../../i18n'; -import { useNotify } from '../../sideEffect'; +import { useNotify } from '../../notification'; import { useGetList, UseGetListHookValue } from '../../dataProvider'; import { SORT_ASC } from './queryReducer'; import { defaultExporter } from '../../export'; diff --git a/packages/ra-core/src/controller/show/useShowController.ts b/packages/ra-core/src/controller/show/useShowController.ts index ddba9b90cb4..7c93f89eb0b 100644 --- a/packages/ra-core/src/controller/show/useShowController.ts +++ b/packages/ra-core/src/controller/show/useShowController.ts @@ -5,7 +5,8 @@ import { useAuthenticated } from '../../auth'; import { Record } from '../../types'; import { useGetOne, useRefresh, UseGetOneHookValue } from '../../dataProvider'; import { useTranslate } from '../../i18n'; -import { useNotify, useRedirect } from '../../sideEffect'; +import { useRedirect } from '../../sideEffect'; +import { useNotify } from '../../notification'; import { useResourceContext, useGetResourceLabel } from '../../core'; /** diff --git a/packages/ra-core/src/core/CoreAdminContext.tsx b/packages/ra-core/src/core/CoreAdminContext.tsx index a611a925428..5a3f96e2505 100644 --- a/packages/ra-core/src/core/CoreAdminContext.tsx +++ b/packages/ra-core/src/core/CoreAdminContext.tsx @@ -9,10 +9,12 @@ import { AuthContext, convertLegacyAuthProvider } from '../auth'; import { DataProviderContext, convertLegacyDataProvider, + defaultDataProvider, } from '../dataProvider'; import createAdminStore from './createAdminStore'; import TranslationProvider from '../i18n/TranslationProvider'; import { ResourceDefinitionContextProvider } from './ResourceDefinitionContext'; +import { NotificationContextProvider } from '../notification'; import { AuthProvider, LegacyAuthProvider, @@ -32,7 +34,7 @@ export interface AdminContextProps { children?: AdminChildren; customReducers?: object; dashboard?: DashboardComponent; - dataProvider: DataProvider | LegacyDataProvider; + dataProvider?: DataProvider | LegacyDataProvider; queryClient?: QueryClient; history?: History; i18nProvider?: I18nProvider; @@ -88,16 +90,18 @@ React-admin requires a valid dataProvider function to work.`); - - - - {children} - - - + + + + + {children} + + + + @@ -120,4 +124,8 @@ React-admin requires a valid dataProvider function to work.`); } }; +CoreAdminContext.defaultProps = { + dataProvider: defaultDataProvider, +}; + export default CoreAdminContext; diff --git a/packages/ra-core/src/core/CoreAdminUI.tsx b/packages/ra-core/src/core/CoreAdminUI.tsx index 28fe11bd2a5..a97d30de406 100644 --- a/packages/ra-core/src/core/CoreAdminUI.tsx +++ b/packages/ra-core/src/core/CoreAdminUI.tsx @@ -29,6 +29,7 @@ export interface AdminUIProps { loginPage?: LoginComponent | boolean; logout?: ComponentType; menu?: ComponentType; + notification?: ComponentType; ready?: ComponentType; title?: TitleComponent; } diff --git a/packages/ra-core/src/dataProvider/combineDataProviders.ts b/packages/ra-core/src/dataProvider/combineDataProviders.ts index f1add4fcedc..30fc56295fa 100644 --- a/packages/ra-core/src/dataProvider/combineDataProviders.ts +++ b/packages/ra-core/src/dataProvider/combineDataProviders.ts @@ -1,5 +1,5 @@ import { DataProvider } from '../types'; -import defaultDataProvider from './defaultDataProvider'; +import { defaultDataProvider } from './defaultDataProvider'; export type DataProviderMatcher = (resource: string) => DataProvider; diff --git a/packages/ra-core/src/dataProvider/defaultDataProvider.ts b/packages/ra-core/src/dataProvider/defaultDataProvider.ts index 8455e83d442..d459ae3f4b8 100644 --- a/packages/ra-core/src/dataProvider/defaultDataProvider.ts +++ b/packages/ra-core/src/dataProvider/defaultDataProvider.ts @@ -1,4 +1,4 @@ -export default { +export const defaultDataProvider = { create: () => Promise.resolve({ data: null }), // avoids adding a context in tests delete: () => Promise.resolve({ data: null }), // avoids adding a context in tests deleteMany: () => Promise.resolve({ data: [] }), // avoids adding a context in tests diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index f4681e3995a..a50caf76773 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -1,7 +1,7 @@ import { useContext, useMemo } from 'react'; import DataProviderContext from './DataProviderContext'; -import defaultDataProvider from './defaultDataProvider'; +import { defaultDataProvider } from './defaultDataProvider'; import validateResponseFormat from './validateResponseFormat'; import { DataProvider } from '../types'; import useLogoutIfAccessDenied from '../auth/useLogoutIfAccessDenied'; diff --git a/packages/ra-core/src/i18n/useSetLocale.tsx b/packages/ra-core/src/i18n/useSetLocale.tsx index f906a4cbe01..b26dd57df24 100644 --- a/packages/ra-core/src/i18n/useSetLocale.tsx +++ b/packages/ra-core/src/i18n/useSetLocale.tsx @@ -1,7 +1,7 @@ import { useContext, useCallback } from 'react'; import { TranslationContext } from './TranslationContext'; -import { useNotify } from '../sideEffect'; +import { useNotify } from '../notification'; /** * Set the current locale using the TranslationContext diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index 92fd724c1ad..c0296837902 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -12,8 +12,7 @@ export * from './inference'; export * from './util'; export * from './controller'; export * from './form'; - -export { getNotification } from './reducer'; +export * from './notification'; export * from './sideEffect'; export * from './types'; diff --git a/packages/ra-core/src/notification/NotificationContext.ts b/packages/ra-core/src/notification/NotificationContext.ts new file mode 100644 index 00000000000..7829d08db06 --- /dev/null +++ b/packages/ra-core/src/notification/NotificationContext.ts @@ -0,0 +1,44 @@ +import { createContext } from 'react'; + +import { NotificationPayload } from './types'; + +export type NotificationContextType = { + notifications: NotificationPayload[]; + addNotification: (notification: NotificationPayload) => void; + takeNotification: () => NotificationPayload | void; + resetNotifications: () => void; +}; + +/** + * Context for the notification state and modifiers + * + * @example // display notifications + * import { useNotificationContext } from 'react-admin'; + * + * const App = () => { + * const { notifications } = useNotificationContext(); + * return ( + *
    + * {notifications.map(({ message }) => ( + *
  • { message }
  • + * ))} + *
+ * ); + * }; + * + * @example // reset notifications + * import { useNotificationContext } from 'react-admin'; + * + * const ResetNotificationsButton = () => { + * const { resetNotifications } = useNotificationContext(); + * return ( + * + * ); + * }; + */ +export const NotificationContext = createContext({ + notifications: [], + addNotification: () => {}, + takeNotification: () => {}, + resetNotifications: () => {}, +}); diff --git a/packages/ra-core/src/notification/NotificationContextProvider.tsx b/packages/ra-core/src/notification/NotificationContextProvider.tsx new file mode 100644 index 00000000000..966338979a4 --- /dev/null +++ b/packages/ra-core/src/notification/NotificationContextProvider.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { useState, useCallback } from 'react'; + +import { NotificationPayload } from './types'; +import { NotificationContext } from './NotificationContext'; + +export const NotificationContextProvider = ({ children }) => { + const [notifications, setNotifications] = useState( + [] + ); + + const addNotification = useCallback((notification: NotificationPayload) => { + setNotifications(notifications => [...notifications, notification]); + }, []); + + const takeNotification = useCallback(() => { + const [notification, ...rest] = notifications; + setNotifications(rest); + return notification; + }, [notifications]); + + const resetNotifications = useCallback(() => { + setNotifications([]); + }, []); + + return ( + + {children} + + ); +}; diff --git a/packages/ra-core/src/notification/index.ts b/packages/ra-core/src/notification/index.ts new file mode 100644 index 00000000000..5d86fe1524f --- /dev/null +++ b/packages/ra-core/src/notification/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './useNotify'; +export * from './NotificationContext'; +export * from './NotificationContextProvider'; +export * from './useNotificationContext'; diff --git a/packages/ra-core/src/notification/types.ts b/packages/ra-core/src/notification/types.ts new file mode 100644 index 00000000000..d985c8ddea6 --- /dev/null +++ b/packages/ra-core/src/notification/types.ts @@ -0,0 +1,18 @@ +export type NotificationType = 'success' | 'info' | 'warning' | 'error'; + +export interface NotificationOptions { + // The duration in milliseconds the notification is shown + autoHideDuration?: number; + // Arguments used to translate the message + messageArgs?: any; + // If true, the notification shows the message in multiple lines + multiLine?: boolean; + // If true, the notification shows an Undo button + undoable?: boolean; +} + +export interface NotificationPayload { + readonly message: string; + readonly type: NotificationType; + readonly notificationOptions?: NotificationOptions; +} diff --git a/packages/ra-core/src/notification/useNotificationContext.ts b/packages/ra-core/src/notification/useNotificationContext.ts new file mode 100644 index 00000000000..92577a09646 --- /dev/null +++ b/packages/ra-core/src/notification/useNotificationContext.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { NotificationContext } from './NotificationContext'; + +export const useNotificationContext = () => useContext(NotificationContext); diff --git a/packages/ra-core/src/notification/useNotify.spec.tsx b/packages/ra-core/src/notification/useNotify.spec.tsx new file mode 100644 index 00000000000..dc7450b8807 --- /dev/null +++ b/packages/ra-core/src/notification/useNotify.spec.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { useEffect } from 'react'; +import { render, screen } from '@testing-library/react'; + +import { CoreAdminContext } from '../core'; +import { useNotify } from './useNotify'; +import { useNotificationContext } from './useNotificationContext'; + +const Notify = ({ + type = undefined, + message, + undoable, + autoHideDuration, + multiLine, +}: any) => { + const notify = useNotify(); + useEffect(() => { + notify(message, { + type, + undoable, + autoHideDuration, + multiLine, + }); + }, [notify]); // eslint-disable-line react-hooks/exhaustive-deps + return null; +}; + +const Notifications = () => { + const { notifications } = useNotificationContext(); + return {JSON.stringify(notifications)}; +}; + +describe('useNotify', () => { + it('should show a multiline notification message', () => { + render( + + + + + ); + screen.getByText( + JSON.stringify([ + { + message: 'One Line\nTwo Lines\nThree Lines', + type: 'info', + notificationOptions: { + multiLine: true, + }, + }, + ]) + ); + }); + + it('should show a notification message of type "warning"', () => { + render( + + + + + ); + screen.getByText( + JSON.stringify([ + { + message: 'Notification message', + type: 'warning', + notificationOptions: { + autoHideDuration: 4000, + }, + }, + ]) + ); + }); + + it('should show a notification when no type is assigned', () => { + render( + + + + + ); + screen.getByText( + JSON.stringify([ + { + message: 'Notification message', + type: 'info', + notificationOptions: {}, + }, + ]) + ); + }); +}); diff --git a/packages/ra-core/src/notification/useNotify.ts b/packages/ra-core/src/notification/useNotify.ts new file mode 100644 index 00000000000..8346ca7c217 --- /dev/null +++ b/packages/ra-core/src/notification/useNotify.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; + +import { useNotificationContext } from './useNotificationContext'; +import { NotificationType, NotificationOptions } from './types'; + +/** + * Hook for Notification Side Effect + * + * @example + * + * const notify = useNotify(); + * // simple message (info level) + * notify('Level complete'); + * // specify level + * notify('A problem occurred', { type: 'warning' }) + * // pass arguments to the translation function + * notify('Deleted %{count} elements', { type: 'info', messageArgs: { smart_count: 23 } }) + * // show the action as undoable in the notification + * notify('Post renamed', { type: 'info', undoable: true }) + */ +export const useNotify = () => { + const { addNotification } = useNotificationContext(); + return useCallback( + ( + message: string, + options: NotificationOptions & { type?: NotificationType } = {} + ) => { + const { + type: messageType = 'info', + ...notificationOptions + } = options; + addNotification({ + message, + type: messageType, + notificationOptions, + }); + }, + [addNotification] + ); +}; diff --git a/packages/ra-core/src/reducer/admin/index.ts b/packages/ra-core/src/reducer/admin/index.ts index 84aea5cbe59..2bb30109cd1 100644 --- a/packages/ra-core/src/reducer/admin/index.ts +++ b/packages/ra-core/src/reducer/admin/index.ts @@ -1,6 +1,5 @@ import { combineReducers, Reducer } from 'redux'; -import notifications from './notifications'; import ui from './ui'; import { selectedIds } from './selectedIds'; import { expandedRows } from './expandedRows'; @@ -17,7 +16,6 @@ export default combineReducers({ * * @see https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests */ - notifications: notifications || defaultReducer, ui: ui || defaultReducer, selectedIds: selectedIds || defaultReducer, expandedRows: expandedRows || defaultReducer, diff --git a/packages/ra-core/src/reducer/admin/notifications.ts b/packages/ra-core/src/reducer/admin/notifications.ts deleted file mode 100644 index d61a0071c36..00000000000 --- a/packages/ra-core/src/reducer/admin/notifications.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Reducer } from 'redux'; -import { - SHOW_NOTIFICATION, - ShowNotificationAction, - HIDE_NOTIFICATION, - HideNotificationAction, - RESET_NOTIFICATION, - ResetNotificationAction, - NotificationPayload, -} from '../../actions/notificationActions'; -import { UNDO, UndoAction } from '../../actions/undoActions'; - -type ActionTypes = - | ShowNotificationAction - | HideNotificationAction - | ResetNotificationAction - | UndoAction - | { type: 'OTHER_TYPE' }; - -type State = NotificationPayload[]; - -const initialState = []; - -const notificationsReducer: Reducer = ( - previousState = initialState, - action: ActionTypes -) => { - switch (action.type) { - case SHOW_NOTIFICATION: - return previousState.concat(action.payload); - case HIDE_NOTIFICATION: - case UNDO: - return previousState.slice(1); - case RESET_NOTIFICATION: - return initialState; - default: - return previousState; - } -}; - -export default notificationsReducer; -/** - * Returns the first available notification to show - * @param {Object} state - Redux state - */ -export const getNotification = state => state.admin.notifications[0]; diff --git a/packages/ra-core/src/reducer/index.ts b/packages/ra-core/src/reducer/index.ts index b9b7b5180da..a420be03ec3 100644 --- a/packages/ra-core/src/reducer/index.ts +++ b/packages/ra-core/src/reducer/index.ts @@ -3,8 +3,6 @@ import { combineReducers, Reducer } from 'redux'; import admin from './admin'; import { ReduxState } from '../types'; -export { getNotification } from './admin/notifications'; - interface CustomReducers { [key: string]: Reducer; } diff --git a/packages/ra-core/src/sideEffect/index.ts b/packages/ra-core/src/sideEffect/index.ts index 627685ace2d..addcb9cadbe 100644 --- a/packages/ra-core/src/sideEffect/index.ts +++ b/packages/ra-core/src/sideEffect/index.ts @@ -1,8 +1,7 @@ import useRedirect, { RedirectionSideEffect } from './useRedirect'; -import useNotify from './useNotify'; import useUnselectAll from './useUnselectAll'; import useUnselect from './useUnselect'; export type { RedirectionSideEffect }; -export { useRedirect, useNotify, useUnselectAll, useUnselect }; +export { useRedirect, useUnselectAll, useUnselect }; diff --git a/packages/ra-core/src/sideEffect/useNotify.spec.tsx b/packages/ra-core/src/sideEffect/useNotify.spec.tsx deleted file mode 100644 index 35f4ad51165..00000000000 --- a/packages/ra-core/src/sideEffect/useNotify.spec.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from 'react'; -import { useEffect } from 'react'; -import { renderWithRedux } from 'ra-test'; -import { useNotify } from './index'; - -const Notification = ({ - type = undefined, - message, - undoable = false, - autoHideDuration = 4000, - multiLine = false, - shortSignature = false, -}) => { - const notify = useNotify(); - useEffect(() => { - if (shortSignature) { - notify(message, { - type, - undoable, - autoHideDuration, - multiLine, - }); - } else { - jest.spyOn(console, 'warn').mockImplementationOnce(() => {}); - notify(message, type, {}, undoable, autoHideDuration, multiLine); - } - }, [ - message, - undoable, - autoHideDuration, - shortSignature, - multiLine, - type, - notify, - ]); - return null; -}; - -describe('useNotify', () => { - it('should show a multiline notification message', () => { - const { dispatch } = renderWithRedux( - - ); - - expect(dispatch).toHaveBeenCalledTimes(1); - }); - - it('should show a notification message of type "warning"', () => { - const { dispatch } = renderWithRedux( - - ); - - expect(dispatch).toHaveBeenCalledTimes(1); - }); - - it('should show a notification when no type is assigned', () => { - const { dispatch } = renderWithRedux( - - ); - - expect(dispatch).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/ra-core/src/sideEffect/useNotify.ts b/packages/ra-core/src/sideEffect/useNotify.ts deleted file mode 100644 index 0f86f68009f..00000000000 --- a/packages/ra-core/src/sideEffect/useNotify.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import { - showNotification, - NotificationType, - NotificationOptions, -} from '../actions/notificationActions'; -import warning from '../util/warning'; - -/** - * Hook for Notification Side Effect - * - * @example - * - * const notify = useNotify(); - * // simple message (info level) - * notify('Level complete'); - * // specify level - * notify('A problem occurred', 'warning') - * // pass arguments to the translation function - * notify('Deleted %{count} elements', 'info', { smart_count: 23 }) - * // show the action as undoable in the notification - * notify('Post renamed', 'info', {}, true) - */ -const useNotify = () => { - const dispatch = useDispatch(); - return useCallback( - ( - message: string, - type?: - | NotificationType - | (NotificationOptions & { type?: NotificationType }), - messageArgs: any = {}, - undoable: boolean = false, - autoHideDuration?: number, - multiLine?: boolean - ) => { - if (typeof type === 'string') { - warning( - true, - 'This way of calling useNotify callback is deprecated. Please use the new syntax passing notify("[Your message]", { ...restOfArguments })' - ); - dispatch( - showNotification( - message, - (type || 'info') as NotificationType, - { - messageArgs, - undoable, - autoHideDuration, - multiLine, - } - ) - ); - } else { - const { type: messageType = 'info', ...options } = type || {}; - dispatch(showNotification(message, messageType, options)); - } - }, - [dispatch] - ); -}; - -export default useNotify; diff --git a/packages/ra-test/src/TestContext.spec.tsx b/packages/ra-test/src/TestContext.spec.tsx index 39c4305e132..8c3e11f430c 100644 --- a/packages/ra-test/src/TestContext.spec.tsx +++ b/packages/ra-test/src/TestContext.spec.tsx @@ -2,13 +2,11 @@ import * as React from 'react'; import expect from 'expect'; import { render, screen } from '@testing-library/react'; -import { showNotification } from 'ra-core'; import TestContext, { defaultStore } from './TestContext'; import { WithDataProvider } from './TestContext.stories'; const primedStore = { admin: { - notifications: [], ui: {}, selectedIds: {}, expandedRows: {}, @@ -67,46 +65,6 @@ describe('TestContext.js', () => { }); describe('enableReducers options', () => { - it('should update the state when set to TRUE', () => { - let testStore; - render( - - {({ store }) => { - testStore = store; - return foo; - }} - - ); - expect(testStore.getState()).toEqual(primedStore); - - testStore.dispatch(showNotification('here')); - - expect(testStore.getState()).toEqual({ - ...primedStore, - admin: { - ...primedStore.admin, - notifications: [{ message: 'here', type: 'info' }], - }, - }); - }); - - it('should NOT update the state when set to FALSE (default)', () => { - let testStore; - render( - - {({ store }) => { - testStore = store; - return foo; - }} - - ); - expect(testStore.getState()).toEqual(defaultStore); - - testStore.dispatch(showNotification('here')); - - expect(testStore.getState()).toEqual(defaultStore); - }); - it('should initilize the state with customReducers initialState', () => { let testStore; render( diff --git a/packages/ra-test/src/TestContext.tsx b/packages/ra-test/src/TestContext.tsx index cd6eaac0f58..db5309d004f 100644 --- a/packages/ra-test/src/TestContext.tsx +++ b/packages/ra-test/src/TestContext.tsx @@ -14,7 +14,6 @@ export const defaultStore = { expandedRows: {}, selectedIds: {}, listParams: {}, - notifications: [], }, }; diff --git a/packages/react-admin/src/AdminContext.tsx b/packages/ra-ui-materialui/src/AdminContext.tsx similarity index 66% rename from packages/react-admin/src/AdminContext.tsx rename to packages/ra-ui-materialui/src/AdminContext.tsx index b018d8e92b9..5935b8f80e1 100644 --- a/packages/react-admin/src/AdminContext.tsx +++ b/packages/ra-ui-materialui/src/AdminContext.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { CoreAdminContext, AdminContextProps } from 'ra-core'; -import { defaultTheme, ThemeProvider } from 'ra-ui-materialui'; -import { defaultI18nProvider } from './defaultI18nProvider'; +import { defaultTheme } from './defaultTheme'; +import { ThemeProvider } from './layout/Theme'; export const AdminContext = (props: AdminContextProps) => { const { theme = defaultTheme, ...rest } = props; @@ -12,8 +12,5 @@ export const AdminContext = (props: AdminContextProps) => { ); }; -AdminContext.defaultProps = { - i18nProvider: defaultI18nProvider, -}; AdminContext.displayName = 'AdminContext'; diff --git a/packages/ra-ui-materialui/src/AdminUI.tsx b/packages/ra-ui-materialui/src/AdminUI.tsx new file mode 100644 index 00000000000..1df13fb3446 --- /dev/null +++ b/packages/ra-ui-materialui/src/AdminUI.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { CoreAdminUI, AdminUIProps } from 'ra-core'; +import { + Layout as DefaultLayout, + LoadingPage, + NotFound, + Notification, +} from './layout'; +import { Login, Logout } from './auth'; + +export const AdminUI = ({ notification, ...props }: AdminUIProps) => ( + <> + + {createElement(notification)} + +); + +AdminUI.defaultProps = { + layout: DefaultLayout, + catchAll: NotFound, + loading: LoadingPage, + loginPage: Login, + logout: Logout, + notification: Notification, +}; diff --git a/packages/ra-ui-materialui/src/auth/Login.tsx b/packages/ra-ui-materialui/src/auth/Login.tsx index 1eeb3c08b9d..069b17af442 100644 --- a/packages/ra-ui-materialui/src/auth/Login.tsx +++ b/packages/ra-ui-materialui/src/auth/Login.tsx @@ -15,7 +15,6 @@ import LockIcon from '@mui/icons-material/Lock'; import { useNavigate } from 'react-router-dom'; import { LoginComponentProps, useCheckAuth } from 'ra-core'; -import { Notification as DefaultNotification } from '../layout'; import { LoginForm as DefaultLoginForm } from './LoginForm'; /** @@ -42,7 +41,6 @@ export const Login = (props: LoginProps) => { classes: classesOverride, className, children, - notification, backgroundImage, ...rest } = props; @@ -96,7 +94,6 @@ export const Login = (props: LoginProps) => { {children} - {notification ? createElement(notification) : null} ); }; @@ -108,7 +105,6 @@ export interface LoginProps children?: ReactNode; classes?: object; className?: string; - notification?: ComponentType; } const PREFIX = 'RaLogin'; @@ -155,5 +151,4 @@ Login.propTypes = { Login.defaultProps = { children: , - notification: DefaultNotification, }; diff --git a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx index 4063124d905..e544564583c 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx +++ b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx @@ -2,19 +2,18 @@ import * as React from 'react'; import { render, waitFor, fireEvent } from '@testing-library/react'; import expect from 'expect'; import { - DataProviderContext, + useNotificationContext, DataProvider, SaveContextProvider, FormContextProvider, } from 'ra-core'; -import { renderWithRedux, TestContext } from 'ra-test'; import { createTheme, ThemeProvider } from '@mui/material/styles'; -import { QueryClientProvider, QueryClient } from 'react-query'; import { SaveButton } from './SaveButton'; import { Toolbar, SimpleForm } from '../form'; import { Edit } from '../detail'; import { TextInput } from '../input'; +import { AdminContext } from '../AdminContext'; const theme = createTheme(); @@ -53,7 +52,7 @@ describe('', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const { getByLabelText } = render( - + @@ -61,7 +60,7 @@ describe('', () => { - + ); expect(spy).not.toHaveBeenCalled(); @@ -74,7 +73,7 @@ describe('', () => { it('should render a disabled button', () => { const { getByLabelText } = render( - + @@ -82,14 +81,14 @@ describe('', () => { - + ); expect(getByLabelText('ra.action.save')['disabled']).toEqual(true); }); it('should render as submit type when submitOnEnter is true', () => { const { getByLabelText } = render( - + @@ -97,7 +96,7 @@ describe('', () => { - + ); expect(getByLabelText('ra.action.save').getAttribute('type')).toEqual( 'submit' @@ -106,7 +105,7 @@ describe('', () => { it('should render as button type when submitOnEnter is false', () => { const { getByLabelText } = render( - + @@ -114,7 +113,7 @@ describe('', () => { - + ); expect(getByLabelText('ra.action.save').getAttribute('type')).toEqual( @@ -125,7 +124,7 @@ describe('', () => { it('should trigger submit action when clicked if no saving is in progress', () => { const onSubmit = jest.fn(); const { getByLabelText } = render( - + @@ -136,7 +135,7 @@ describe('', () => { - + ); fireEvent.click(getByLabelText('ra.action.save')); @@ -147,7 +146,7 @@ describe('', () => { const onSubmit = jest.fn(); const { getByLabelText } = render( - + @@ -158,7 +157,7 @@ describe('', () => { - + ); fireEvent.click(getByLabelText('ra.action.save')); @@ -167,36 +166,39 @@ describe('', () => { it('should show a notification if the form is not valid', () => { const onSubmit = jest.fn(); - let dispatchSpy; + let notificationsSpy; + const Notification = () => { + const { notifications } = useNotificationContext(); + React.useEffect(() => { + notificationsSpy = notifications; + }, [notifications]); + return null; + }; const { getByLabelText } = render( - - {({ store }) => { - dispatchSpy = jest.spyOn(store, 'dispatch'); - return ( - - - - - - - - ); - }} - + + + + + + + + + + ); fireEvent.click(getByLabelText('ra.action.save')); - expect(dispatchSpy).toHaveBeenCalledWith({ - payload: { + expect(notificationsSpy).toEqual([ + { message: 'ra.message.invalid_form', type: 'warning', + notificationOptions: {}, }, - type: 'RA/SHOW_NOTIFICATION', - }); + ]); expect(onSubmit).toHaveBeenCalled(); }); @@ -233,22 +235,14 @@ describe('', () => { ); - const { - queryByDisplayValue, - getByLabelText, - getByText, - } = renderWithRedux( - - - - - }> - - - - - - + const { queryByDisplayValue, getByLabelText, getByText } = render( + + + }> + + + + ); // waitFor for the dataProvider.getOne() return await waitFor(() => { @@ -282,22 +276,14 @@ describe('', () => { ); - const { - queryByDisplayValue, - getByLabelText, - getByText, - } = renderWithRedux( - - - - - }> - - - - - - + const { queryByDisplayValue, getByLabelText, getByText } = render( + + + }> + + + + ); // waitFor for the dataProvider.getOne() return await waitFor(() => { @@ -335,22 +321,14 @@ describe('', () => { ); - const { - queryByDisplayValue, - getByLabelText, - getByText, - } = renderWithRedux( - - - - - }> - - - - - - + const { queryByDisplayValue, getByLabelText, getByText } = render( + + + }> + + + + ); // waitFor for the dataProvider.getOne() return await waitFor(() => { @@ -388,22 +366,14 @@ describe('', () => { return undefined; }; - const { queryByDisplayValue, getByLabelText } = renderWithRedux( - - - - - - - - - - - , - { admin: { resources: { posts: { data: {} } } } } + const { queryByDisplayValue, getByLabelText } = render( + + + + + + + ); // waitFor for the dataProvider.getOne() return await waitFor(() => { diff --git a/packages/ra-ui-materialui/src/index.ts b/packages/ra-ui-materialui/src/index.ts index 6214440b1cf..bfc29959fd2 100644 --- a/packages/ra-ui-materialui/src/index.ts +++ b/packages/ra-ui-materialui/src/index.ts @@ -9,3 +9,5 @@ export * from './layout'; export * from './Link'; export * from './list'; export * from './types'; +export * from './AdminUI'; +export * from './AdminContext'; diff --git a/packages/ra-ui-materialui/src/layout/Layout.tsx b/packages/ra-ui-materialui/src/layout/Layout.tsx index eec714c0cfe..beec6e7cf36 100644 --- a/packages/ra-ui-materialui/src/layout/Layout.tsx +++ b/packages/ra-ui-materialui/src/layout/Layout.tsx @@ -14,7 +14,6 @@ import { CoreLayoutProps, ReduxState } from 'ra-core'; import { AppBar as DefaultAppBar, AppBarProps } from './AppBar'; import { Sidebar as DefaultSidebar } from './Sidebar'; import { Menu as DefaultMenu, MenuProps } from './Menu'; -import { Notification as DefaultNotification } from './Notification'; import { Error, ErrorProps } from './Error'; import { SkipNavigationButton } from '../button'; @@ -27,7 +26,6 @@ export const Layout = (props: LayoutProps) => { error: errorComponent, logout, menu: Menu = DefaultMenu, - notification: Notification = DefaultNotification, sidebar: Sidebar = DefaultSidebar, title, ...rest @@ -43,45 +41,36 @@ export const Layout = (props: LayoutProps) => { }; return ( - <> - - -
- -
- - - -
+ +
+ +
+ + + +
+ ( + + )} > - ( - - )} - > - {children} - -
-
-
- - - + {children} + +
+
+
+
); }; @@ -93,7 +82,6 @@ export interface LayoutProps className?: string; error?: ComponentType; menu?: ComponentType; - notification?: ComponentType; sidebar?: ComponentType<{ children: ReactNode }>; } diff --git a/packages/ra-ui-materialui/src/layout/Notification.tsx b/packages/ra-ui-materialui/src/layout/Notification.tsx index 367a2dc8c3e..256cab336eb 100644 --- a/packages/ra-ui-materialui/src/layout/Notification.tsx +++ b/packages/ra-ui-materialui/src/layout/Notification.tsx @@ -2,15 +2,11 @@ import * as React from 'react'; import { styled, Theme } from '@mui/material/styles'; import { useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { useSelector, useDispatch } from 'react-redux'; import { Button, Snackbar, SnackbarProps } from '@mui/material'; import classnames from 'classnames'; import { - hideNotification, - getNotification, - undo, - complete, + useNotificationContext, undoableEventEmitter, useTranslate, } from 'ra-core'; @@ -24,54 +20,66 @@ export const Notification = (props: NotificationProps) => { multiLine = false, ...rest } = props; + const { notifications, takeNotification } = useNotificationContext(); const [open, setOpen] = useState(false); - const notification = useSelector(getNotification); - const dispatch = useDispatch(); + const [messageInfo, setMessageInfo] = React.useState(undefined); const translate = useTranslate(); useEffect(() => { - setOpen(!!notification); - }, [notification]); + if (notifications.length && !messageInfo) { + // Set a new snack when we don't have an active one + setMessageInfo(takeNotification()); + setOpen(true); + } else if (notifications.length && messageInfo && open) { + // Close an active snack when a new one is added + setOpen(false); + } + }, [notifications, messageInfo, open, takeNotification]); const handleRequestClose = useCallback(() => { setOpen(false); }, [setOpen]); const handleExited = useCallback(() => { - if (notification && notification.undoable) { - dispatch(complete()); + if (messageInfo && messageInfo.notificationOptions.undoable) { undoableEventEmitter.emit('end', { isUndo: false }); } - dispatch(hideNotification()); - }, [dispatch, notification]); + setMessageInfo(undefined); + }, [messageInfo]); const handleUndo = useCallback(() => { - dispatch(undo()); undoableEventEmitter.emit('end', { isUndo: true }); - }, [dispatch]); + setOpen(false); + }, []); - if (!notification) return null; + if (!messageInfo) return null; return ( { ); }; +Admin.defaultProps = { + i18nProvider: defaultI18nProvider, +}; + export default Admin; export interface AdminProps extends CoreAdminProps { diff --git a/packages/react-admin/src/AdminUI.tsx b/packages/react-admin/src/AdminUI.tsx deleted file mode 100644 index 941fc043910..00000000000 --- a/packages/react-admin/src/AdminUI.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react'; -import { CoreAdminUI, AdminUIProps } from 'ra-core'; -import { - Layout as DefaultLayout, - LoadingPage, - Login, - Logout, - NotFound, -} from 'ra-ui-materialui'; - -export const AdminUI = (props: AdminUIProps) => ; - -AdminUI.defaultProps = { - layout: DefaultLayout, - catchAll: NotFound, - loading: LoadingPage, - loginPage: Login, - logout: Logout, -}; diff --git a/packages/react-admin/src/index.ts b/packages/react-admin/src/index.ts index 76c6ceb8a60..b974f793c88 100644 --- a/packages/react-admin/src/index.ts +++ b/packages/react-admin/src/index.ts @@ -1,7 +1,5 @@ export * from './Admin'; -export * from './AdminContext'; export * from './AdminRouter'; -export * from './AdminUI'; export * from './defaultI18nProvider'; export * from 'ra-core'; export * from 'ra-ui-materialui'; From 0a32c50c42e9c375105adf1a441fb7e7eb396133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Thu, 13 Jan 2022 22:32:23 +0100 Subject: [PATCH 02/10] Fix remaining tests --- .../ra-core/src/auth/useCheckAuth.spec.tsx | 4 +- .../src/auth/useLogoutIfAccessDenied.spec.tsx | 4 +- .../src/reducer/admin/notifications.spec.ts | 68 ------------------- 3 files changed, 4 insertions(+), 72 deletions(-) delete mode 100644 packages/ra-core/src/reducer/admin/notifications.spec.ts diff --git a/packages/ra-core/src/auth/useCheckAuth.spec.tsx b/packages/ra-core/src/auth/useCheckAuth.spec.tsx index c609dcd9966..005c80e22e0 100644 --- a/packages/ra-core/src/auth/useCheckAuth.spec.tsx +++ b/packages/ra-core/src/auth/useCheckAuth.spec.tsx @@ -6,12 +6,12 @@ import { render, waitFor } from '@testing-library/react'; import { useCheckAuth } from './useCheckAuth'; import AuthContext from './AuthContext'; import useLogout from './useLogout'; -import useNotify from '../sideEffect/useNotify'; +import { useNotify } from '../notification/useNotify'; import { AuthProvider } from '../types'; import { defaultAuthParams } from './useAuthProvider'; jest.mock('./useLogout'); -jest.mock('../sideEffect/useNotify'); +jest.mock('../notification/useNotify'); const logout = jest.fn(); useLogout.mockImplementation(() => logout); diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx index d2a19a440c7..d6239c9a641 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx @@ -7,7 +7,7 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom'; import useLogoutIfAccessDenied from './useLogoutIfAccessDenied'; import AuthContext from './AuthContext'; import useLogout from './useLogout'; -import useNotify from '../sideEffect/useNotify'; +import { useNotify } from '../notification/useNotify'; import { AuthProvider } from '../types'; let loggedIn = true; @@ -48,7 +48,7 @@ const TestComponent = ({ }; jest.mock('./useLogout'); -jest.mock('../sideEffect/useNotify'); +jest.mock('../notification/useNotify'); //@ts-expect-error useLogout.mockImplementation(() => { diff --git a/packages/ra-core/src/reducer/admin/notifications.spec.ts b/packages/ra-core/src/reducer/admin/notifications.spec.ts deleted file mode 100644 index 7d38d7ac51c..00000000000 --- a/packages/ra-core/src/reducer/admin/notifications.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import expect from 'expect'; -import { - HIDE_NOTIFICATION, - SHOW_NOTIFICATION, - NotificationType, -} from '../../actions/notificationActions'; -import reducer from './notifications'; - -describe('notifications reducer', () => { - it('should return empty notification by default', () => { - expect(reducer(undefined, { type: 'foo' })).toEqual([]); - }); - it('should set autoHideDuration when passed in payload', () => { - expect([ - { message: 'test', type: 'info', autoHideDuration: 1337 }, - ]).toEqual( - reducer(undefined, { - type: SHOW_NOTIFICATION, - payload: { - message: 'test', - type: 'info', - autoHideDuration: 1337, - }, - }) - ); - }); - it('should set multiLine when passed in payload', () => { - expect([{ message: 'test', type: 'info', multiLine: true }]).toEqual( - reducer(undefined, { - type: SHOW_NOTIFICATION, - payload: { - message: 'test', - type: 'info', - multiLine: true, - }, - }) - ); - }); - it('should set text and type upon SHOW_NOTIFICATION', () => { - expect([{ message: 'foo', type: 'warning' }]).toEqual( - reducer(undefined, { - type: SHOW_NOTIFICATION, - payload: { - message: 'foo', - type: 'warning', - }, - }) - ); - }); - it('should have no elements upon last HIDE_NOTIFICATION', () => { - expect([]).toEqual( - reducer([{ message: 'foo', type: 'warning' as NotificationType }], { - type: HIDE_NOTIFICATION, - }) - ); - }); - it('should have one less notification upon HIDE_NOTIFICATION with multiple notifications', () => { - const notifications = [ - { message: 'foo', type: 'info' as NotificationType }, - { message: 'bar', type: 'info' as NotificationType }, - ]; - expect(notifications.length - 1).toEqual( - reducer(notifications, { - type: HIDE_NOTIFICATION, - }).length - ); - }); -}); From 0e9308b32db9fd82b9252be50dbc68607eae2152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Thu, 13 Jan 2022 23:03:53 +0100 Subject: [PATCH 03/10] Improve docs --- docs/Theming.md | 29 +++------------- docs/useNotify.md | 41 ++++++++++++++++++----- packages/ra-core/src/core/CoreAdmin.tsx | 4 +-- packages/ra-core/src/core/CoreAdminUI.tsx | 10 ++---- packages/ra-core/src/core/components.ts | 6 ++-- packages/ra-ui-materialui/src/AdminUI.tsx | 8 +++-- packages/react-admin/src/Admin.tsx | 4 +++ 7 files changed, 54 insertions(+), 48 deletions(-) diff --git a/docs/Theming.md b/docs/Theming.md index df42b432893..fe1cd648632 100644 --- a/docs/Theming.md +++ b/docs/Theming.md @@ -375,7 +375,7 @@ const App = () => ( ); ``` -Your custom layout can extend the default `` component if you only want to override the sidebar, the appBar, the menu, the notification component or the error page. For instance: +Your custom layout can extend the default `` component if you only want to override the sidebar, the appBar, the menu or the error page. For instance: ```jsx // in src/MyLayout.js @@ -383,14 +383,12 @@ import { Layout } from 'react-admin'; import MyAppBar from './MyAppBar'; import MySidebar from './MySidebar'; import MyMenu from './MyMenu'; -import MyNotification from './MyNotification'; const MyLayout = props => ; export default MyLayout; @@ -528,7 +526,6 @@ import { ThemeProvider } from '@material-ui/styles'; import { AppBar, Menu, - Notification, Sidebar, setSidebarVisibility, ComponentPropType, @@ -588,7 +585,6 @@ const MyLayout = ({ {children} - ); @@ -607,8 +603,6 @@ MyLayout.propTypes = { export default MyLayout; ``` -**Tip**: Don't forget to render a `` component in your custom layout, otherwise the undoable updates will never be sent to the server. That's because part of the "undo" logic of react-admin lies in the `` component. - ## Adding a Breadcrumb The `` component is part of `ra-navigation`, an [Enterprise Edition](https://marmelab.com/ra-enterprise) module. It displays a breadcrumb based on a site structure that you can override at will. @@ -1026,28 +1020,15 @@ const MyNotification = props => ; - -export default MyLayout; -``` - -Then, use this layout in the `` `layout` prop: +To use this custom notification component, pass it to the `` component as the `notification` prop: ```jsx // in src/App.js -import MyLayout from './MyLayout'; +import MyNotification from './MyNotification'; +import dataProvider from './dataProvider'; const App = () => ( - + // ... ); diff --git a/docs/useNotify.md b/docs/useNotify.md index ebb022bfd5f..bbd5e9152b8 100644 --- a/docs/useNotify.md +++ b/docs/useNotify.md @@ -21,25 +21,27 @@ const NotifyButton = () => { The callback takes 2 arguments: - The message to display -- an `options` object with the following keys - - The `type` of the notification (`info`, `success` or `warning` - the default is `info`) - - A `messageArgs` object 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. - - An `undoable` boolean. Set it to `true` if the notification should contain an "undo" button - - An `autoHideDuration` number. Set it to `0` if the notification should not be dismissible. - - A `multiLine` boolean. Set it to `true` if the notification message should be shown in more than one line. +- an `options` object with the following keys: + - `type`: The notification type (`info`, `success` or `warning` - the default is `info`) + - `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. + - `undoable`: Set it to `true` if the notification should contain an "undo" button + - `autoHideDuration`: Duration (in milliseconds) after which the notification hides. Set it to `0` if the notification should not be dismissible. + - `multiLine`: Set it to `true` if the notification message should be shown in more than one line. Here are more examples of `useNotify` calls: ```js // notify a warning -notify(`This is a warning`, 'warning'); +notify(`This is a warning`, { type: 'warning' }); // pass translation arguments notify('item.created', { type: 'info', messageArgs: { resource: 'post' } }); // send an undoable notification notify('Element updated', { type: 'info', undoable: true }); ``` -**Tip**: When using `useNotify` as a side effect for an `undoable` mutation, you MUST set the `undoable` option to `true`, otherwise the "undo" button will not appear, and the actual update will never occur. +## `undoable` Option + +When using `useNotify` as a side effect for an `undoable` mutation, you MUST set the `undoable` option to `true`, otherwise the "undo" button will not appear, and the actual update will never occur. ```jsx import * as React from 'react'; @@ -61,3 +63,26 @@ const PostEdit = () => { ); } ``` + +## `autoHideDuration` Option + +You can define a custom delay for hiding for a given notification. + +```jsx +import { useNotify } from 'react-admin'; + +const LogoutButton = () => { + const notify = useNotify(); + const logout = useLogout(); + + const handleClick = () => { + logout().then(() => { + notify('Form submitted successfully', { autoHideDuration: 5000 }); + }); + }; + + return ; +}; +``` + +To change the default delay for all notifications, check [the Theming documentation](./Theming.md#notifications). \ No newline at end of file diff --git a/packages/ra-core/src/core/CoreAdmin.tsx b/packages/ra-core/src/core/CoreAdmin.tsx index 242a7714028..0b304988377 100644 --- a/packages/ra-core/src/core/CoreAdmin.tsx +++ b/packages/ra-core/src/core/CoreAdmin.tsx @@ -4,7 +4,7 @@ import { QueryClient } from 'react-query'; import { History } from 'history'; import CoreAdminContext from './CoreAdminContext'; -import CoreAdminUI from './CoreAdminUI'; +import { CoreAdminUI } from './CoreAdminUI'; import { AuthProvider, LegacyAuthProvider, @@ -20,8 +20,6 @@ import { TitleComponent, } from '../types'; -export type ChildrenFunction = () => ComponentType[]; - /** * Main admin component, entry point to the application. * diff --git a/packages/ra-core/src/core/CoreAdminUI.tsx b/packages/ra-core/src/core/CoreAdminUI.tsx index a97d30de406..723621f0dd3 100644 --- a/packages/ra-core/src/core/CoreAdminUI.tsx +++ b/packages/ra-core/src/core/CoreAdminUI.tsx @@ -19,7 +19,7 @@ export type ChildrenFunction = () => ComponentType[]; const DefaultLayout = ({ children }: CoreLayoutProps) => <>{children}; -export interface AdminUIProps { +export interface CoreAdminUIProps { catchAll?: CatchAllComponent; children?: AdminChildren; dashboard?: DashboardComponent; @@ -29,15 +29,11 @@ export interface AdminUIProps { loginPage?: LoginComponent | boolean; logout?: ComponentType; menu?: ComponentType; - notification?: ComponentType; ready?: ComponentType; title?: TitleComponent; } -// for BC -export type CoreAdminUIProps = AdminUIProps; - -const CoreAdminUI = (props: AdminUIProps) => { +export const CoreAdminUI = (props: CoreAdminUIProps) => { const { catchAll = Noop, children, @@ -97,5 +93,3 @@ const CoreAdminUI = (props: AdminUIProps) => { }; const Noop = () => null; - -export default CoreAdminUI; diff --git a/packages/ra-core/src/core/components.ts b/packages/ra-core/src/core/components.ts index 9d0b18f5f72..9cb2aa10082 100644 --- a/packages/ra-core/src/core/components.ts +++ b/packages/ra-core/src/core/components.ts @@ -1,12 +1,12 @@ import CoreAdminContext, { AdminContextProps } from './CoreAdminContext'; -import CoreAdminUI, { AdminUIProps } from './CoreAdminUI'; import createAdminStore from './createAdminStore'; export * from './CoreAdmin'; +export * from './CoreAdminUI'; export * from './CoreAdminRouter'; export * from './CustomRoutes'; export * from './Resource'; -export type { AdminContextProps, AdminUIProps }; +export type { AdminContextProps }; -export { CoreAdminContext, CoreAdminUI, createAdminStore }; +export { CoreAdminContext, createAdminStore }; diff --git a/packages/ra-ui-materialui/src/AdminUI.tsx b/packages/ra-ui-materialui/src/AdminUI.tsx index 1df13fb3446..5511f63b398 100644 --- a/packages/ra-ui-materialui/src/AdminUI.tsx +++ b/packages/ra-ui-materialui/src/AdminUI.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { createElement } from 'react'; -import { CoreAdminUI, AdminUIProps } from 'ra-core'; +import { createElement, ComponentType } from 'react'; +import { CoreAdminUI, CoreAdminUIProps } from 'ra-core'; import { Layout as DefaultLayout, LoadingPage, @@ -16,6 +16,10 @@ export const AdminUI = ({ notification, ...props }: AdminUIProps) => ( ); +export interface AdminUIProps extends CoreAdminUIProps { + notification?: ComponentType; +} + AdminUI.defaultProps = { layout: DefaultLayout, catchAll: NotFound, diff --git a/packages/react-admin/src/Admin.tsx b/packages/react-admin/src/Admin.tsx index c34200a64f5..a16ec319a5a 100644 --- a/packages/react-admin/src/Admin.tsx +++ b/packages/react-admin/src/Admin.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { ComponentType } from 'react'; import { CoreAdminProps } from 'ra-core'; import { AdminUI, AdminContext } from 'ra-ui-materialui'; import { ThemeOptions } from '@mui/material'; @@ -104,6 +105,7 @@ export const Admin = (props: AdminProps) => { loginPage, logoutButton, menu, // deprecated, use a custom layout instead + notification, ready, theme, title = 'React Admin', @@ -146,6 +148,7 @@ export const Admin = (props: AdminProps) => { loading={loading} loginPage={loginPage} logout={authProvider ? logoutButton : undefined} + notification={notification} ready={ready} > {children} @@ -162,4 +165,5 @@ export default Admin; export interface AdminProps extends CoreAdminProps { theme?: ThemeOptions; + notification?: ComponentType; } From 0f28f5e5260eef523d08cbe54f7a857b002bac2d Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Jan 2022 10:06:24 +0100 Subject: [PATCH 04/10] Add upgrade instructions --- UPGRADE.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index d74197e75cd..c6a07e966ad 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -716,6 +716,26 @@ const PostEdit = () => { }; ``` +## `useNotify` Now Takes An Options Object + +When a component has to display a notification, developers may want to tweak the type, duration, translatino arguments, or the ability to undo the action. The callback returned by `useNotify()` used to accept a long series of argument, but the syntax wasn't very intuitive. To improve the developer experience, these options are now part of an `options` object, passed as second argument. + +```diff +```jsx +import { useNotify } from 'react-admin'; + +const NotifyButton = () => { + const notify = useNotify(); + const handleClick = () => { +- notify(`Comment approved`, 'success', undefined, true); ++ notify(`Comment approved`, { type: 'success', undoable: true }); + } + return ; +}; +``` + +Check [the `useNotify`documentation](https://marmelab.com/react-admin/useNotify.html) for more information. + ## The `useVersion` Hook Was Removed React-admin v3 relied on a global `version` variable stored in the Redux state to force page refresh. This is no longer the case, as the refresh functionality is handled by react-query. @@ -1168,6 +1188,24 @@ export const Menu = (props) => { Reducers for the **list parameters** (current sort & filters, selected ids, expanded rows) have moved up to the root reducer (so they don't need the resource to be registered first). This shouldn't impact you if you used the react-admin hooks (`useListParams`, `useSelection`) to read the state. +React-admin no longer uses Redux for **notifications**. Instead, it uses a custom context. This change is backwards compatible, as the APIs for the `useNotify` and the `` component are the same. If you used to `dispatch` a `showNotification` action, you'll have to use the `useNotify` hook instead: + +```diff +-import { useDispatch } from 'react-redux'; +-import { showNotification } from 'react-admin'; ++import { useNotify } from 'react-admin'; + +const NotifyButton = () => { +- const dispatch = useDispatch(); ++ const notify = useNotify(); + const handleClick = () => { +- dispatch(showNotification('Comment approved', 'success')); ++ notify('Comment approved', { type: 'success' }); + } + return ; +}; +``` + ## Redux-Saga Was Removed The use of sagas has been deprecated for a while. React-admin v4 doesn't support them anymore. That means that the Redux actions don't include meta parameters anymore to trigger sagas, the Redux store doesn't include the saga middleware, and the saga-based side effects were removed. @@ -1411,6 +1449,58 @@ const Layout = (props) => { }; ``` +## The `` Component Is Included By `` Rather Than `` + +If you customized the `` component (e.g. to tweak the delay after which a notification disappears), you passed your custom notification component to the `` component. The `` is now included by the `` component, which facilitates custom layouts and login screens. As a consequence, you'll need to move your custom notification component to the `` component. + +```diff +// in src/MyNotification.js +import { Notification } from 'react-admin'; + +export const MyNotification = props => ( + +); + +// in src/MyLayout.js +-import { Layout } from 'react-admin'; +-import { MyNotification } from './MyNotification'; + +-export const MyLayout = props => ( +- +-); + +// in src/App.js +-import { MyLayout } from './MyLayout'; ++import { MyNotification } from './MyNotification'; +import dataProvider from './dataProvider'; + +const App = () => ( +- ++ + // ... + +); +``` + +If you had a custom Layout and/or Login component, you no longer need to include the `` component. + +```diff +-import { Notification } from 'react-admin'; + +export const MyLayout = ({ + children, + dashboard, + logout, + title, +}) => { + // ... + return (<> + // ... +- + ); +}; +``` + # Upgrade to 3.0 We took advantage of the major release to fix all the problems in react-admin that required a breaking change. As a consequence, you'll need to do many small changes in the code of existing react-admin v2 applications. Follow this step-by-step guide to upgrade to react-admin v3. From 85c95b918070387d0561e898db567471631e4d68 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Jan 2022 12:12:34 +0100 Subject: [PATCH 05/10] Review --- UPGRADE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UPGRADE.md b/UPGRADE.md index c6a07e966ad..bd47f9cc873 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -718,7 +718,7 @@ const PostEdit = () => { ## `useNotify` Now Takes An Options Object -When a component has to display a notification, developers may want to tweak the type, duration, translatino arguments, or the ability to undo the action. The callback returned by `useNotify()` used to accept a long series of argument, but the syntax wasn't very intuitive. To improve the developer experience, these options are now part of an `options` object, passed as second argument. +When a component has to display a notification, developers may want to tweak the type, duration, translation arguments, or the ability to undo the action. The callback returned by `useNotify()` used to accept a long series of argument, but the syntax wasn't very intuitive. To improve the developer experience, these options are now part of an `options` object, passed as second argument. ```diff ```jsx From 8ea0e6510bd34de1430f595681e5e669478995fc Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Jan 2022 12:22:15 +0100 Subject: [PATCH 06/10] Review --- .../NotificationContextProvider.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/ra-core/src/notification/NotificationContextProvider.tsx b/packages/ra-core/src/notification/NotificationContextProvider.tsx index 966338979a4..5500df1c58f 100644 --- a/packages/ra-core/src/notification/NotificationContextProvider.tsx +++ b/packages/ra-core/src/notification/NotificationContextProvider.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { NotificationPayload } from './types'; import { NotificationContext } from './NotificationContext'; @@ -23,15 +23,18 @@ export const NotificationContextProvider = ({ children }) => { setNotifications([]); }, []); + const contextValue = useMemo( + () => ({ + notifications, + addNotification, + takeNotification, + resetNotifications, + }), + [notifications] // eslint-disable-line react-hooks/exhaustive-deps + ); + return ( - + {children} ); From 5bcbc284cf12d188ddb70eb0e51e396d96c33538 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Jan 2022 13:53:13 +0100 Subject: [PATCH 07/10] Fix linter warnings --- packages/ra-ui-materialui/src/auth/Login.tsx | 9 +-------- packages/ra-ui-materialui/src/list/ListActions.tsx | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/ra-ui-materialui/src/auth/Login.tsx b/packages/ra-ui-materialui/src/auth/Login.tsx index 069b17af442..781005d12b9 100644 --- a/packages/ra-ui-materialui/src/auth/Login.tsx +++ b/packages/ra-ui-materialui/src/auth/Login.tsx @@ -1,12 +1,5 @@ import * as React from 'react'; -import { - HtmlHTMLAttributes, - ComponentType, - createElement, - ReactNode, - useRef, - useEffect, -} from 'react'; +import { HtmlHTMLAttributes, ReactNode, useRef, useEffect } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Card, Avatar } from '@mui/material'; diff --git a/packages/ra-ui-materialui/src/list/ListActions.tsx b/packages/ra-ui-materialui/src/list/ListActions.tsx index 1a6688cca0f..6c2b4b9e413 100644 --- a/packages/ra-ui-materialui/src/list/ListActions.tsx +++ b/packages/ra-ui-materialui/src/list/ListActions.tsx @@ -57,7 +57,6 @@ export const ListActions = (props: ListActionsProps) => { currentSort, displayedFilters, filterValues, - selectedIds, showFilter, total, } = useListContext(props); From 75c9d33a1e1fba17ee636064c828a1581721d1b9 Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Fri, 14 Jan 2022 14:36:14 +0100 Subject: [PATCH 08/10] Update docs/useNotify.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AnĂ­bal Svarcas --- docs/useNotify.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/useNotify.md b/docs/useNotify.md index bbd5e9152b8..69c69ab6690 100644 --- a/docs/useNotify.md +++ b/docs/useNotify.md @@ -66,7 +66,7 @@ const PostEdit = () => { ## `autoHideDuration` Option -You can define a custom delay for hiding for a given notification. +You can define a custom delay for hiding a given notification. ```jsx import { useNotify } from 'react-admin'; From f03c096956093307d58a86f38376a4510d7aca44 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Jan 2022 15:32:50 +0100 Subject: [PATCH 09/10] Fix bad rebase --- packages/ra-core/src/form/FormWithRedirect.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-core/src/form/FormWithRedirect.tsx b/packages/ra-core/src/form/FormWithRedirect.tsx index 8f48c49e872..bb591b58fd9 100644 --- a/packages/ra-core/src/form/FormWithRedirect.tsx +++ b/packages/ra-core/src/form/FormWithRedirect.tsx @@ -7,7 +7,7 @@ import useResetSubmitErrors from './useResetSubmitErrors'; import sanitizeEmptyValues from './sanitizeEmptyValues'; import getFormInitialValues from './getFormInitialValues'; import { Record as RaRecord } from '../types'; -import { useNotify } from '../sideEffect'; +import { useNotify } from '../notification'; import { useSaveContext, SaveHandler } from '../controller'; import { useRecordContext, OptionalRecordContextProvider } from '../controller'; import submitErrorsMutators from './submitErrorsMutators'; From f0eb7ff46a084d6762ecb9ca1099a000dbbf369c Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Jan 2022 15:35:45 +0100 Subject: [PATCH 10/10] Fix tests --- .../src/form/FormWithRedirect.spec.tsx | 9 +-- .../src/button/SaveButton.spec.tsx | 57 ------------------- 2 files changed, 5 insertions(+), 61 deletions(-) diff --git a/packages/ra-core/src/form/FormWithRedirect.spec.tsx b/packages/ra-core/src/form/FormWithRedirect.spec.tsx index e5bb3fdc365..230decc94aa 100644 --- a/packages/ra-core/src/form/FormWithRedirect.spec.tsx +++ b/packages/ra-core/src/form/FormWithRedirect.spec.tsx @@ -1,11 +1,10 @@ import * as React from 'react'; -import { useSelector } from 'react-redux'; import { fireEvent, screen, render, waitFor } from '@testing-library/react'; import { CoreAdminContext } from '../core'; import { testDataProvider } from '../dataProvider'; -import { getNotification } from '../reducer'; import { FormWithRedirect } from './FormWithRedirect'; +import { useNotificationContext } from '../notification'; import useInput from './useInput'; import { required } from './validate'; @@ -228,8 +227,10 @@ describe('FormWithRedirect', () => { it('Displays a notification on submit when invalid', async () => { const Notification = () => { - const notification = useSelector(getNotification); - return

{notification?.message}

; + const { notifications } = useNotificationContext(); + return notifications.length > 0 ? ( +
{notifications[0].message}
+ ) : null; }; render( diff --git a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx index fc01eb668f1..c2b3b2b825c 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx +++ b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx @@ -138,63 +138,6 @@ describe('', () => { expect(onSubmit).not.toHaveBeenCalled(); }); - it('should show a notification if the form is not valid', () => { - const onSubmit = jest.fn(); - let notificationsSpy; - const Notification = () => { - const { notifications } = useNotificationContext(); - React.useEffect(() => { - notificationsSpy = notifications; - }, [notifications]); - return null; - }; - - const { getByLabelText } = render( - - - - - - - - - - - ); - - fireEvent.click(getByLabelText('ra.action.save')); - expect(notificationsSpy).toEqual([ - { - message: 'ra.message.invalid_form', - type: 'warning', - notificationOptions: {}, - }, - ]); - expect(onSubmit).toHaveBeenCalled(); - }); - - const defaultEditProps = { - basePath: '', - id: '123', - resource: 'posts', - location: { - pathname: '/customers/123', - search: '', - state: {}, - hash: '', - }, - match: { - params: { id: 123 }, - isExact: true, - path: '/customers/123', - url: '/customers/123', - }, - mutationMode: 'pessimistic', - }; - it('should allow to override the onSuccess side effects', async () => { const dataProvider = ({ getOne: () =>