diff --git a/docs/Upgrade.md b/docs/Upgrade.md index e74401558a0..75a1c05c7f1 100644 --- a/docs/Upgrade.md +++ b/docs/Upgrade.md @@ -482,6 +482,142 @@ import { FieldProps, useRecordContext } from 'react-admin'; } ``` +## `useWarnWhenUnsavedChanges` Requires A Data Router + +The `useWarnWhenUnsavedChanges` hook was reimplemented using [`useBlocker`](https://reactrouter.com/en/main/hooks/use-blocker) from `react-router`. As a consequence, it now requires a [data router](https://reactrouter.com/en/main/routers/picking-a-router) to be used. + +The `` component has been updated to use [`createHashRouter`](https://reactrouter.com/en/main/routers/create-hash-router) internally by default, which is a data router. So you don't need to change anything if you are using `react-admin`'s internal router. + +If you are using an external router, you will need to migrate it to a data router to be able to use the `warnWhenUnsavedChanges` feature. + +```diff +import * as React from 'react'; +import { Admin, Resource } from 'react-admin'; +import { createRoot } from 'react-dom/client'; +-import { BrowserRouter } from 'react-router-dom'; ++import { createBrowserRouter, RouterProvider } from 'react-router-dom'; + +import dataProvider from './dataProvider'; +import posts from './posts'; + +const App = () => ( +- + + + +- +); + ++const router = createBrowserRouter([{ path: '*', element: }]); + +const container = document.getElementById('root'); +const root = createRoot(container); + +root.render( + +- ++ + +); +``` + +### Minor Changes + +Due to the new implementation using `useBlocker`, you may also notice the following minor changes: + +- `useWarnWhenUnsavedChanges` will also open a confirmation dialog (and block the navigation) if a navigation is fired when the form is currently submitting (submission will continue in the background). +- [Due to browser constraints](https://stackoverflow.com/questions/38879742/is-it-possible-to-display-a-custom-message-in-the-beforeunload-popup), the message displayed in the confirmation dialog when closing the browser's tab cannot be customized (it is managed by the browser). + +## `` Prop Was Removed + +The `` prop was deprecated since version 4. It is no longer supported. + +The most common use-case for this prop was inside unit tests (and stories), to pass a `MemoryRouter` and control the `initialEntries`. + +To that purpose, `react-admin` now exports a `TestMemoryHistory` component that you can use in your tests: + +```diff +import { render, screen } from '@testing-library/react'; +-import { createMemoryHistory } from 'history'; +-import { CoreAdminContext } from 'react-admin'; ++import { CoreAdminContext, TestMemoryRouter } from 'react-admin'; +import * as React from 'react'; + +describe('my test suite', () => { + it('my test', async () => { +- const history = createMemoryHistory({ initialEntries: ['/'] }); + render( ++ +- ++ +
My Component
+
++
+ ); + await screen.findByText('My Component'); + }); +}); +``` + +### Codemod + +To help you migrate your tests, we've created a codemod that will replace the `` prop with the `` component. + +> **DISCLAIMER** +> +> This codemod was used to migrate the react-admin test suite, but it was never designed to cover all cases, and was not tested against other code bases. You can try using it as basis to see if it helps migrating your code base, but please review the generated changes thoroughly! +> +> Applying the codemod might break your code formatting, so please don't forget to run `prettier` and/or `eslint` after you've applied the codemod! + +For `.js` or `.jsx` files: + +```sh +npx jscodeshift ./path/to/src/ \ + --extensions=js,jsx \ + --transform=./node_modules/ra-core/codemods/replace-Admin-history.ts +``` + +For `.ts` or `.tsx` files: + +```sh +npx jscodeshift ./path/to/src/ \ + --extensions=ts,tsx \ + --parser=tsx \ + --transform=./node_modules/ra-core/codemods/replace-Admin-history.ts +``` + +## `` Was Removed + +Along with the removal of the `` prop, we also removed the (undocumented) `` component. + +Just like for ``, the most common use-case for this component was inside unit tests (and stories), to control the `initialEntries`. + +Here too, you can use `TestMemoryHistory` as a replacement: + +```diff +import { render, screen } from '@testing-library/react'; +-import { createMemoryHistory } from 'history'; +-import { CoreAdminContext, HistoryRouter } from 'react-admin'; ++import { CoreAdminContext, TestMemoryRouter } from 'react-admin'; +import * as React from 'react'; + +describe('my test suite', () => { + it('my test', async () => { +- const history = createMemoryHistory({ initialEntries: ['/'] }); + render( +- ++ + +
My Component
+
+-
++
+ ); + await screen.findByText('My Component'); + }); +}); +``` + ## Upgrading to v4 If you are on react-admin v3, follow the [Upgrading to v4](https://marmelab.com/react-admin/doc/4.16/Upgrade.html) guide before upgrading to v5. diff --git a/examples/crm/package.json b/examples/crm/package.json index 0c632ca0075..9b59e5fb76f 100644 --- a/examples/crm/package.json +++ b/examples/crm/package.json @@ -19,8 +19,8 @@ "react-admin": "^4.12.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", - "react-router": "^6.1.0", - "react-router-dom": "^6.1.0" + "react-router": "^6.22.0", + "react-router-dom": "^6.22.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.1.4", diff --git a/examples/demo/package.json b/examples/demo/package.json index ebc2b60f08b..ae47b9b9699 100644 --- a/examples/demo/package.json +++ b/examples/demo/package.json @@ -32,8 +32,8 @@ "react": "^18.0.0", "react-admin": "^4.12.0", "react-dom": "^18.2.0", - "react-router": "^6.1.0", - "react-router-dom": "^6.1.0", + "react-router": "^6.22.0", + "react-router-dom": "^6.22.0", "recharts": "^2.1.15" }, "scripts": { diff --git a/examples/simple/package.json b/examples/simple/package.json index 5e0f04ef472..3d5b36042a0 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -28,8 +28,8 @@ "react-admin": "^4.16.11", "react-dom": "^18.2.0", "react-hook-form": "^7.43.9", - "react-router": "^6.1.0", - "react-router-dom": "^6.1.0" + "react-router": "^6.22.0", + "react-router-dom": "^6.22.0" }, "devDependencies": { "@hookform/devtools": "^4.0.2", diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-Authenticated.input.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-Authenticated.input.tsx new file mode 100644 index 00000000000..da1ea550024 --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-Authenticated.input.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import expect from 'expect'; +import { render, screen, waitFor } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { Routes, Route, useLocation } from 'react-router-dom'; + +// @ts-ignore +import { memoryStore } from '../store'; +// @ts-ignore +import { CoreAdminContext } from '../core'; +// @ts-ignore +import { useNotificationContext } from '../notification'; +// @ts-ignore +import { Authenticated } from './Authenticated'; + +describe('', () => { + const Foo = () =>
Foo
; + + it('should render its child by default', async () => { + const authProvider = { + login: () => Promise.reject('bad method'), + logout: () => Promise.reject('bad method'), + checkAuth: jest.fn().mockResolvedValueOnce(''), + checkError: () => Promise.reject('bad method'), + getPermissions: () => Promise.reject('bad method'), + }; + const store = memoryStore(); + const reset = jest.spyOn(store, 'reset'); + + render( + + + + + + ); + expect(screen.queryByText('Foo')).not.toBeNull(); + expect(reset).toHaveBeenCalledTimes(0); + }); + + it('should logout, redirect to login and show a notification after a tick if the auth fails', async () => { + const authProvider = { + login: jest.fn().mockResolvedValue(''), + logout: jest.fn().mockResolvedValue(''), + checkAuth: jest.fn().mockRejectedValue(undefined), + checkError: jest.fn().mockResolvedValue(''), + getPermissions: jest.fn().mockResolvedValue(''), + }; + const store = memoryStore(); + const reset = jest.spyOn(store, 'reset'); + const history = createMemoryHistory(); + + const Login = () => { + const location = useLocation(); + return ( +
+ {(location.state as any).nextPathname} +
+ ); + }; + + 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(reset).toHaveBeenCalledTimes(1); + expect(notificationsSpy).toEqual([ + { + message: 'ra.auth.auth_check_error', + type: 'error', + notificationOptions: {}, + }, + ]); + expect(screen.getByLabelText('nextPathname').innerHTML).toEqual( + '/' + ); + }); + }); +}); diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-Authenticated.output.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-Authenticated.output.tsx new file mode 100644 index 00000000000..8f84b141388 --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-Authenticated.output.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import expect from 'expect'; +import { render, screen, waitFor } from '@testing-library/react'; +import { TestMemoryRouter } from 'react-admin'; +import { Routes, Route, useLocation } from 'react-router-dom'; + +// @ts-ignore +import { memoryStore } from '../store'; +// @ts-ignore +import { CoreAdminContext } from '../core'; +// @ts-ignore +import { useNotificationContext } from '../notification'; +// @ts-ignore +import { Authenticated } from './Authenticated'; + +describe('', () => { + const Foo = () =>
Foo
; + + it('should render its child by default', async () => { + const authProvider = { + login: () => Promise.reject('bad method'), + logout: () => Promise.reject('bad method'), + checkAuth: jest.fn().mockResolvedValueOnce(''), + checkError: () => Promise.reject('bad method'), + getPermissions: () => Promise.reject('bad method'), + }; + const store = memoryStore(); + const reset = jest.spyOn(store, 'reset'); + + render( + + + + + + ); + expect(screen.queryByText('Foo')).not.toBeNull(); + expect(reset).toHaveBeenCalledTimes(0); + }); + + it('should logout, redirect to login and show a notification after a tick if the auth fails', async () => { + const authProvider = { + login: jest.fn().mockResolvedValue(''), + logout: jest.fn().mockResolvedValue(''), + checkAuth: jest.fn().mockRejectedValue(undefined), + checkError: jest.fn().mockResolvedValue(''), + getPermissions: jest.fn().mockResolvedValue(''), + }; + const store = memoryStore(); + const reset = jest.spyOn(store, 'reset'); + + const Login = () => { + const location = useLocation(); + return ( +
+ {(location.state as any).nextPathname} +
+ ); + }; + + 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(reset).toHaveBeenCalledTimes(1); + expect(notificationsSpy).toEqual([ + { + message: 'ra.auth.auth_check_error', + type: 'error', + notificationOptions: {}, + }, + ]); + expect(screen.getByLabelText('nextPathname').innerHTML).toEqual( + '/' + ); + }); + }); +}); diff --git a/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-useEditController.input.tsx b/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-useEditController.input.tsx new file mode 100644 index 00000000000..ec195102595 --- /dev/null +++ b/packages/ra-core/codemods/__testfixtures__/replace-Admin-history-useEditController.input.tsx @@ -0,0 +1,1057 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import expect from 'expect'; +import { createMemoryHistory } from 'history'; +import * as React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router'; + +import { + EditContextProvider, + SaveContextProvider, + useEditController, + // @ts-ignore +} from '..'; +// @ts-ignore +import { CoreAdminContext } from '../../core'; +// @ts-ignore +import { testDataProvider, useUpdate } from '../../dataProvider'; +// @ts-ignore +import undoableEventEmitter from '../../dataProvider/undoableEventEmitter'; +// @ts-ignore +import { Form, InputProps, useInput } from '../../form'; +// @ts-ignore +import { useNotificationContext } from '../../notification'; +// @ts-ignore +import { DataProvider } from '../../types'; +// @ts-ignore +import { Middleware, useRegisterMutationMiddleware } from '../saveContext'; +// @ts-ignore +import { EditController } from './EditController'; + +describe('useEditController', () => { + const defaultProps = { + id: 12, + resource: 'posts', + }; + + it('should call the dataProvider.getOne() function on mount', async () => { + const getOne = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ data: { id: 12, title: 'hello' } }) + ); + const dataProvider = ({ getOne } as unknown) as DataProvider; + render( + + + {({ record }) =>
{record && record.title}
} +
+
+ ); + await waitFor(() => { + expect(getOne).toHaveBeenCalled(); + expect(screen.queryAllByText('hello')).toHaveLength(1); + }); + }); + + it('should decode the id from the route params', async () => { + const getOne = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ data: { id: 'test?', title: 'hello' } }) + ); + const dataProvider = ({ getOne } as unknown) as DataProvider; + const history = createMemoryHistory({ + initialEntries: ['/posts/test%3F'], + }); + + render( + + + + {({ record }) => ( +
{record && record.title}
+ )} + + } + /> +
+
+ ); + await waitFor(() => { + expect(getOne).toHaveBeenCalledWith('posts', { id: 'test?' }); + }); + await waitFor(() => { + expect(screen.queryAllByText('hello')).toHaveLength(1); + }); + }); + + it('should use the id provided through props if any', async () => { + const getOne = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ data: { id: 0, title: 'hello' } }) + ); + const dataProvider = ({ getOne } as unknown) as DataProvider; + const history = createMemoryHistory({ + initialEntries: ['/posts/test%3F'], + }); + + render( + + + + {({ record }) => ( +
{record && record.title}
+ )} + + } + /> +
+
+ ); + await waitFor(() => { + expect(getOne).toHaveBeenCalledWith('posts', { id: 0 }); + }); + await waitFor(() => { + expect(screen.queryAllByText('hello')).toHaveLength(1); + }); + }); + + it('should return the `redirect` provided through props or the default', async () => { + const getOne = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ data: { id: 12, title: 'hello' } }) + ); + const dataProvider = ({ getOne } as unknown) as DataProvider; + const Component = ({ redirect = undefined }) => ( + + + {({ redirect }) =>
{redirect}
} +
+
+ ); + const { rerender } = render(); + await waitFor(() => { + expect(screen.queryAllByText('list')).toHaveLength(1); + }); + + // @ts-ignore + rerender(); + await waitFor(() => { + expect(screen.queryAllByText('show')).toHaveLength(1); + }); + }); + + describe('queryOptions', () => { + it('should accept custom client query options', async () => { + const mock = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const getOne = jest + .fn() + .mockImplementationOnce(() => Promise.reject(new Error())); + const onError = jest.fn(); + const dataProvider = ({ getOne } as unknown) as DataProvider; + render( + + + {() =>
} + + + ); + await waitFor(() => { + expect(getOne).toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + }); + mock.mockRestore(); + }); + + it('should accept a meta in query options', async () => { + const getOne = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ data: { id: 0, title: 'hello' } }) + ); + const dataProvider = ({ getOne } as unknown) as DataProvider; + render( + + + {() =>
} + + + ); + await waitFor(() => { + expect(getOne).toHaveBeenCalledWith('posts', { + id: 12, + meta: { foo: 'bar' }, + }); + }); + }); + }); + + it('should call the dataProvider.update() function on save', async () => { + const update = jest + .fn() + .mockImplementationOnce((_, { id, data, previousData }) => + Promise.resolve({ data: { id, ...previousData, ...data } }) + ); + const dataProvider = ({ + getOne: () => + Promise.resolve({ data: { id: 12, test: 'previous' } }), + update, + } as unknown) as DataProvider; + render( + + + {({ record, save }) => { + return ( + <> +

{record?.test}

+ ; + return ( + <> +

page: {page}

+ + + ); }; it('should synchronize parameters with location and store when sync is enabled', async () => { - const history = createMemoryHistory(); - const navigate = jest.spyOn(history, 'push'); + let location; let storeValue; const StoreReader = () => { const [value] = useStore('posts.listParams'); @@ -378,18 +390,34 @@ describe('useListParams', () => { return null; }; render( - { + location = l; + }} > - - - + + + + + ); fireEvent.click(screen.getByText('update')); await waitFor(() => { - expect(navigate).toHaveBeenCalled(); + expect(location).toEqual( + expect.objectContaining({ + pathname: '/', + search: + '?' + + stringify({ + filter: JSON.stringify({}), + sort: 'id', + order: 'ASC', + page: 10, + perPage: 10, + }), + }) + ); }); expect(storeValue).toEqual({ @@ -402,8 +430,7 @@ describe('useListParams', () => { }); test('should not synchronize parameters with location and store when sync is not enabled', async () => { - const history = createMemoryHistory(); - const navigate = jest.spyOn(history, 'push'); + let location; let storeValue; const StoreReader = () => { const [value] = useStore('posts.listParams'); @@ -414,21 +441,37 @@ describe('useListParams', () => { }; render( - { + location = l; + }} > - - - + + + + + ); fireEvent.click(screen.getByText('update')); - await waitFor(() => { - expect(navigate).not.toHaveBeenCalled(); - expect(storeValue).toBeUndefined(); - }); + await screen.findByText('page: 10'); + + expect(location).not.toEqual( + expect.objectContaining({ + pathname: '/', + search: + '?' + + stringify({ + filter: JSON.stringify({}), + sort: 'id', + order: 'ASC', + page: 10, + perPage: 10, + }), + }) + ); + expect(storeValue).toBeUndefined(); }); }); }); diff --git a/packages/ra-core/src/controller/show/useShowController.spec.tsx b/packages/ra-core/src/controller/show/useShowController.spec.tsx index a5501fb7ce6..9af23c440fb 100644 --- a/packages/ra-core/src/controller/show/useShowController.spec.tsx +++ b/packages/ra-core/src/controller/show/useShowController.spec.tsx @@ -2,11 +2,11 @@ import * as React from 'react'; import expect from 'expect'; import { render, screen, waitFor } from '@testing-library/react'; import { Route, Routes } from 'react-router'; -import { createMemoryHistory } from 'history'; - import { ShowController } from './ShowController'; + import { CoreAdminContext } from '../../core'; import { DataProvider } from '../../types'; +import { TestMemoryRouter } from '../../routing'; describe('useShowController', () => { const defaultProps = { @@ -43,25 +43,22 @@ describe('useShowController', () => { ); const dataProvider = ({ getOne } as unknown) as DataProvider; render( - - - - {({ record }) => ( -
{record && record.title}
- )} - - } - /> -
-
+ + + + + {({ record }) => ( +
{record && record.title}
+ )} + + } + /> +
+
+
); await waitFor(() => { expect(getOne).toHaveBeenCalledWith('posts', { @@ -82,25 +79,22 @@ describe('useShowController', () => { ); const dataProvider = ({ getOne } as unknown) as DataProvider; render( - - - - {({ record }) => ( -
{record && record.title}
- )} - - } - /> -
-
+ + + + + {({ record }) => ( +
{record && record.title}
+ )} + + } + /> +
+
+
); await waitFor(() => { expect(getOne).toHaveBeenCalledWith('posts', { @@ -121,26 +115,23 @@ describe('useShowController', () => { const onError = jest.fn(); const dataProvider = ({ getOne } as unknown) as DataProvider; render( - - - - {() =>
} - - } - /> - - + + + + + {() =>
} + + } + /> + + + ); await waitFor(() => { expect(getOne).toHaveBeenCalled(); @@ -158,26 +149,23 @@ describe('useShowController', () => { const dataProvider = ({ getOne } as unknown) as DataProvider; render( - - - - {() =>
} - - } - /> - - + + + + + {() =>
} + + } + /> + + + ); await waitFor(() => { expect(getOne).toHaveBeenCalledWith('posts', { diff --git a/packages/ra-core/src/core/CoreAdmin.tsx b/packages/ra-core/src/core/CoreAdmin.tsx index 48e848899fe..ba0690c076f 100644 --- a/packages/ra-core/src/core/CoreAdmin.tsx +++ b/packages/ra-core/src/core/CoreAdmin.tsx @@ -91,7 +91,6 @@ export const CoreAdmin = (props: CoreAdminProps) => { dashboard, dataProvider, disableTelemetry, - history, i18nProvider, queryClient, layout, @@ -109,7 +108,6 @@ export const CoreAdmin = (props: CoreAdminProps) => { dataProvider={dataProvider} i18nProvider={i18nProvider} queryClient={queryClient} - history={history} store={store} > { i18nProvider, store = defaultStore, children, - history, queryClient, } = props; @@ -215,7 +207,7 @@ React-admin requires a valid dataProvider function to work.`); - + diff --git a/packages/ra-core/src/core/CoreAdminRoutes.spec.tsx b/packages/ra-core/src/core/CoreAdminRoutes.spec.tsx index bcbd63f4007..f9e4b3d2c77 100644 --- a/packages/ra-core/src/core/CoreAdminRoutes.spec.tsx +++ b/packages/ra-core/src/core/CoreAdminRoutes.spec.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import expect from 'expect'; -import { MemoryRouter, Route } from 'react-router-dom'; -import { createMemoryHistory } from 'history'; +import { NavigateFunction, Route } from 'react-router-dom'; import { CoreAdminContext } from './CoreAdminContext'; import { CoreAdminRoutes } from './CoreAdminRoutes'; @@ -10,6 +9,7 @@ import { Resource } from './Resource'; import { CustomRoutes } from './CustomRoutes'; import { CoreLayoutProps } from '../types'; import { testDataProvider } from '../dataProvider'; +import { TestMemoryRouter } from '../routing'; const Layout = ({ children }: CoreLayoutProps) =>
Layout {children}
; const CatchAll = () =>
; @@ -22,43 +22,46 @@ describe('', () => { describe('With resources as regular children', () => { it('should render resources and custom routes with and without layout', async () => { - const history = createMemoryHistory(); + let navigate: NavigateFunction | null = null; render( - { + navigate = n; + }} > - - - Foo
} /> - - - Bar
} /> - - PostList} - /> - CommentList} - /> - -
+ + + + Foo
} /> + + + Bar
} /> + + PostList} + /> + CommentList} + /> + +
+
); await screen.findByText('Layout'); - history.push('/posts'); + navigate('/posts'); await screen.findByText('PostList'); - history.push('/comments'); + navigate('/comments'); await screen.findByText('CommentList'); - history.push('/foo'); + navigate('/foo'); await screen.findByText('Foo'); expect(screen.queryByText('Layout')).toBeNull(); - history.push('/bar'); + navigate('/bar'); await screen.findByText('Layout'); await screen.findByText('Bar'); }); @@ -66,99 +69,105 @@ describe('', () => { describe('With children returned from a function as children', () => { it('should render resources and custom routes with and without layout', async () => { - const history = createMemoryHistory(); + let navigate: NavigateFunction | null = null; render( - { + navigate = n; + }} > - - - Foo
} /> - - {() => ( - <> - - Bar
} + + + + Foo
} /> + + {() => ( + <> + + Bar} + /> + + PostList} + /> + CommentList} /> - - PostList} - /> - CommentList} - /> - - )} - -
+ + )} + + + ); - history.push('/foo'); + navigate('/foo'); await screen.findByText('Foo'); expect(screen.queryByText('Layout')).toBeNull(); - history.push('/bar'); + navigate('/bar'); await screen.findByText('Bar'); await screen.findByText('Layout'); await screen.findByText('Bar'); - history.push('/posts'); + navigate('/posts'); await screen.findByText('PostList'); - history.push('/comments'); + navigate('/comments'); await screen.findByText('CommentList'); }); it('should render resources and custom routes with and without layout even without an authProvider', async () => { - const history = createMemoryHistory(); + let navigate: NavigateFunction | null = null; render( - { + navigate = n; + }} > - - - Foo} /> - - {() => ( - <> - - Bar} + + + + Foo} /> + + {() => ( + <> + + Bar} + /> + + PostList} + /> + CommentList} /> - - PostList} - /> - CommentList} - /> - - )} - - + + )} + + + ); - history.push('/foo'); + navigate('/foo'); await screen.findByText('Foo'); expect(screen.queryByText('Layout')).toBeNull(); - history.push('/bar'); + navigate('/bar'); await screen.findByText('Bar'); expect(screen.queryByText('Layout')).not.toBeNull(); - history.push('/posts'); + navigate('/posts'); await screen.findByText('PostList'); - history.push('/comments'); + navigate('/comments'); await screen.findByText('CommentList'); }); @@ -173,30 +182,35 @@ describe('', () => { }; const Custom = () => <>Custom; - const history = createMemoryHistory(); + let navigate: NavigateFunction | null = null; render( - { + navigate = n; + }} > - - - } /> - - {() => new Promise(() => null)} - - + + + } /> + + {() => new Promise(() => null)} + + + ); // Timeout needed because we wait for a second before displaying the loading screen jest.advanceTimersByTime(1010); - history.push('/posts'); + navigate('/posts'); await screen.findByText('Loading'); - history.push('/foo'); + navigate('/foo'); await screen.findByText('Custom'); expect(screen.queryByText('Loading')).toBeNull(); jest.useRealTimers(); @@ -214,7 +228,7 @@ describe('', () => { }; render( - + ', () => { /> - + ); expect(screen.queryByText('PostList')).not.toBeNull(); expect(screen.queryByText('Loading')).toBeNull(); @@ -248,7 +262,7 @@ describe('', () => { }; render( - + ', () => { /> - + ); expect(screen.queryByText('PostList')).toBeNull(); expect(screen.queryByText('Loading')).not.toBeNull(); @@ -285,25 +299,28 @@ describe('', () => { getPermissions: jest.fn().mockResolvedValue(''), }; - const history = createMemoryHistory(); render( - - + - - Login} /> - - PostList} /> - - + + + Login} /> + + PostList} + /> + + + ); expect(screen.queryByText('PostList')).toBeNull(); expect(screen.queryByText('Loading')).not.toBeNull(); @@ -321,29 +338,37 @@ describe('', () => { getPermissions: jest.fn().mockResolvedValue(''), }; - const history = createMemoryHistory(); + let navigate: NavigateFunction | null = null; render( - { + navigate = n; + }} > - - - Custom} /> - Login} /> - - PostList} /> - - + + + Custom} /> + Login} /> + + PostList} + /> + + + ); expect(screen.queryByText('PostList')).not.toBeNull(); expect(screen.queryByText('Loading')).toBeNull(); - history.push('/custom'); + navigate('/custom'); await new Promise(resolve => setTimeout(resolve, 1100)); await waitFor(() => expect(screen.queryByText('Custom')).not.toBeNull() diff --git a/packages/ra-core/src/core/CustomRoutes.authenticated.stories.tsx b/packages/ra-core/src/core/CustomRoutes.authenticated.stories.tsx index 14c5000357d..9d4e76c86ed 100644 --- a/packages/ra-core/src/core/CustomRoutes.authenticated.stories.tsx +++ b/packages/ra-core/src/core/CustomRoutes.authenticated.stories.tsx @@ -14,13 +14,11 @@ export default { }, }; -export const AuthenticatedCustomRoute = (argsOrProps, context) => { - const history = context?.history || argsOrProps.history; +export const AuthenticatedCustomRoute = () => { return ( @@ -34,7 +32,7 @@ export const AuthenticatedCustomRoute = (argsOrProps, context) => { ); }; -const dataProvider = { +const dataProvider: any = { getList: () => Promise.resolve({ data: [], total: 0 }), getOne: () => Promise.resolve({ data: { id: 0 } }), getMany: () => Promise.resolve({ data: [] }), diff --git a/packages/ra-core/src/core/CustomRoutes.spec.tsx b/packages/ra-core/src/core/CustomRoutes.spec.tsx index e579c759d53..369df5f87c2 100644 --- a/packages/ra-core/src/core/CustomRoutes.spec.tsx +++ b/packages/ra-core/src/core/CustomRoutes.spec.tsx @@ -1,25 +1,27 @@ import * as React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; import { AuthenticatedCustomRoute } from './CustomRoutes.authenticated.stories'; import { UnauthenticatedCustomRoute } from './CustomRoutes.unauthenticated.stories'; import { WithLayoutCustomRoute } from './CustomRoutes.withLayout.stories'; +import { TestMemoryRouter } from '../routing'; describe('', () => { test("should render custom routes that don't need authentication even when unauthenticated", () => { - const history = createMemoryHistory({ - initialEntries: ['/password-recovery'], - }); - render(); + render( + + + + ); expect(screen.queryByText('Password recovery')).not.toBeNull(); }); test('should render custom routes that need authentication only when authenticated', async () => { - const history = createMemoryHistory({ - initialEntries: ['/authenticated'], - }); - render(); + render( + + + + ); await waitFor(() => { expect(screen.queryByText('Login page')).not.toBeNull(); @@ -35,10 +37,11 @@ describe('', () => { }); test('should render custom routes that need authentication and layout only when authenticated', async () => { - const history = createMemoryHistory({ - initialEntries: ['/custom'], - }); - render(); + render( + + + + ); await waitFor(() => { expect(screen.queryByText('Login page')).not.toBeNull(); diff --git a/packages/ra-core/src/core/CustomRoutes.unauthenticated.stories.tsx b/packages/ra-core/src/core/CustomRoutes.unauthenticated.stories.tsx index cd3787bedc0..5aa8c058eaa 100644 --- a/packages/ra-core/src/core/CustomRoutes.unauthenticated.stories.tsx +++ b/packages/ra-core/src/core/CustomRoutes.unauthenticated.stories.tsx @@ -14,13 +14,11 @@ export default { }, }; -export const UnauthenticatedCustomRoute = (argsOrProps, context) => { - const history = context?.history || argsOrProps.history; +export const UnauthenticatedCustomRoute = () => { return ( @@ -33,7 +31,7 @@ export const UnauthenticatedCustomRoute = (argsOrProps, context) => { ); }; -const dataProvider = { +const dataProvider: any = { getList: () => Promise.resolve({ data: [], total: 0 }), getOne: () => Promise.resolve({ data: { id: 0 } }), getMany: () => Promise.resolve({ data: [] }), diff --git a/packages/ra-core/src/core/CustomRoutes.withLayout.stories.tsx b/packages/ra-core/src/core/CustomRoutes.withLayout.stories.tsx index 1da55080af3..0c7d28b2be1 100644 --- a/packages/ra-core/src/core/CustomRoutes.withLayout.stories.tsx +++ b/packages/ra-core/src/core/CustomRoutes.withLayout.stories.tsx @@ -14,14 +14,11 @@ export default { }, }; -export const WithLayoutCustomRoute = (argsOrProps, context) => { - const history = context?.history || argsOrProps.history; - +export const WithLayoutCustomRoute = () => { return ( @@ -33,7 +30,7 @@ export const WithLayoutCustomRoute = (argsOrProps, context) => { ); }; -const dataProvider = { +const dataProvider: any = { getList: () => Promise.resolve({ data: [], total: 0 }), getOne: () => Promise.resolve({ data: { id: 0 } }), getMany: () => Promise.resolve({ data: [] }), diff --git a/packages/ra-core/src/core/Resource.spec.tsx b/packages/ra-core/src/core/Resource.spec.tsx index a913208c0a0..041bd9d3d11 100644 --- a/packages/ra-core/src/core/Resource.spec.tsx +++ b/packages/ra-core/src/core/Resource.spec.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { render, screen } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; - import { CoreAdminContext } from './CoreAdminContext'; + import { Resource } from './Resource'; import { Route } from 'react-router'; +import { TestMemoryRouter } from '../routing'; const PostList = () =>
PostList
; const PostEdit = () =>
PostEdit
; @@ -27,23 +27,29 @@ const resource = { describe('', () => { it('renders resource routes by default', async () => { - const history = createMemoryHistory(); + let navigate; render( - - - + { + navigate = n; + }} + > + + + + ); // Resource does not declare a route matching its name, it only renders its child routes // so we don't need to navigate to a path matching its name - history.push('/'); + navigate('/'); await screen.findByText('PostList'); - history.push('/123'); + navigate('/123'); await screen.findByText('PostEdit'); - history.push('/123/show'); + navigate('/123/show'); await screen.findByText('PostShow'); - history.push('/create'); + navigate('/create'); await screen.findByText('PostCreate'); - history.push('/customroute'); + navigate('/customroute'); await screen.findByText('PostCustomRoute'); }); }); diff --git a/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.spec.tsx b/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.spec.tsx index 2e164ca3e69..4b44de0d162 100644 --- a/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.spec.tsx +++ b/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.spec.tsx @@ -2,15 +2,15 @@ import * as React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import expect from 'expect'; import { Route } from 'react-router-dom'; -import { createMemoryHistory } from 'history'; - import { useResourceDefinitions } from './useResourceDefinitions'; + import { CoreAdminContext } from './CoreAdminContext'; import { CoreAdminRoutes } from './CoreAdminRoutes'; import { Resource } from './Resource'; import { CustomRoutes } from './CustomRoutes'; import { CoreLayoutProps } from '../types'; import { AuthProvider, ResourceProps } from '..'; +import { TestMemoryRouter } from '../routing'; const ResourceDefinitionsTestComponent = () => { const definitions = useResourceDefinitions(); @@ -37,55 +37,60 @@ const Loading = () => <>Loading; const Ready = () => <>Ready; const TestedComponent = ({ role }) => { - const history = createMemoryHistory(); - return ( - - - - - {() => - role === 'admin' - ? [, ] - : role === 'user' - ? [] - : [] - } - - + + + + + + {() => + role === 'admin' + ? [ + , + , + ] + : role === 'user' + ? [] + : [] + } + + + ); }; const TestedComponentReturningNull = ({ role }) => { - const history = createMemoryHistory(); - return ( - - - - - {() => - role === 'admin' - ? [, ] - : role === 'user' - ? [] - : null - } - - + + + + + + {() => + role === 'admin' + ? [ + , + , + ] + : role === 'user' + ? [] + : null + } + + + ); }; const TestedComponentWithAuthProvider = ({ callback }) => { - const history = createMemoryHistory(); const authProvider = { login: () => Promise.resolve(), logout: () => Promise.resolve(), @@ -95,17 +100,19 @@ const TestedComponentWithAuthProvider = ({ callback }) => { }; return ( - - - - - {callback} - - + + + + + + {callback} + + + ); }; @@ -127,7 +134,6 @@ ResourceWithPermissions.registerResource = ( }); const TestedComponentWithPermissions = () => { - const history = createMemoryHistory(); const authProvider: AuthProvider = { login: () => Promise.resolve(), logout: () => Promise.resolve(), @@ -157,39 +163,41 @@ const TestedComponentWithPermissions = () => { }; return ( - - - } - create={
} - edit={
} - show={
} - /> - } - create={
} - edit={
} - show={
} - /> - } - create={
} - edit={
} - show={
} - /> - - + + + + } + create={
} + edit={
} + show={
} + /> + } + create={
} + edit={
} + show={
} + /> + } + create={
} + edit={
} + show={
} + /> + + + ); }; -const TestedComponentWithOnlyLazyCustomRoutes = ({ history }) => { +const TestedComponentWithOnlyLazyCustomRoutes = ({ navigateCallback }) => { const [ lazyRoutes, setLazyRoutes, @@ -209,40 +217,42 @@ const TestedComponentWithOnlyLazyCustomRoutes = ({ history }) => { }, [setLazyRoutes]); return ( - - - {lazyRoutes} - - + + + + {lazyRoutes} + + + ); }; const TestedComponentWithForcedRoutes = () => { - const history = createMemoryHistory(); - return ( - - - } - hasCreate - hasEdit - hasShow - /> - } /> - {() => [} hasEdit />]} - - + + + + } + hasCreate + hasEdit + hasShow + /> + } /> + {() => [} hasEdit />]} + + + ); }; @@ -367,13 +377,19 @@ describe('useConfigureAdminRouterFromChildren', () => { ).toBeNull(); }); it('should allow dynamically loaded custom routes without any resources', async () => { - const history = createMemoryHistory(); - render(); + let navigate; + render( + { + navigate = n; + }} + /> + ); expect(screen.queryByText('Ready')).not.toBeNull(); await new Promise(resolve => setTimeout(resolve, 1010)); expect(screen.queryByText('Ready')).toBeNull(); - history.push('/foo'); + navigate('/foo'); await screen.findByText('Foo'); }); it('should support forcing hasEdit hasCreate or hasShow', async () => { diff --git a/packages/ra-core/src/dataProvider/useDataProvider.spec.tsx b/packages/ra-core/src/dataProvider/useDataProvider.spec.tsx index 74f8f6c7ba4..cc5e351ad99 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.spec.tsx +++ b/packages/ra-core/src/dataProvider/useDataProvider.spec.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { useState, useEffect } from 'react'; -import { render, act } from '@testing-library/react'; +import { render, act, screen } from '@testing-library/react'; import expect from 'expect'; import { useDataProvider } from './useDataProvider'; @@ -96,22 +96,19 @@ describe('useDataProvider', () => { expect(queryByTestId('error').textContent).toBe('foo'); }); - it('should throw a meaningful error when the dataProvider throws a sync error', async () => { + it('should display a meaningful error when the dataProvider throws a sync error', async () => { const c = jest.spyOn(console, 'error').mockImplementation(() => {}); const getOne = jest.fn(() => { throw new Error('foo'); }); const dataProvider = { getOne }; - const r = () => - render( - - - - ); - expect(r).toThrow( - new Error( - 'The dataProvider threw an error. It should return a rejected Promise instead.' - ) + render( + + + + ); + await screen.findByText( + 'The dataProvider threw an error. It should return a rejected Promise instead.' ); c.mockRestore(); }); diff --git a/packages/ra-core/src/dataProvider/useGetRecordId.spec.tsx b/packages/ra-core/src/dataProvider/useGetRecordId.spec.tsx index a0a62f5caa3..d9e5221a85a 100644 --- a/packages/ra-core/src/dataProvider/useGetRecordId.spec.tsx +++ b/packages/ra-core/src/dataProvider/useGetRecordId.spec.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; import { useGetRecordId } from './useGetRecordId'; import { render, screen } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router-dom'; import { RecordContextProvider } from '..'; +import { TestMemoryRouter } from '../routing'; + describe('useGetRecordId', () => { const UseGetRecordId = (props: any) => { const recordId = useGetRecordId(props.id); @@ -40,22 +42,22 @@ describe('useGetRecordId', () => { it('should return the record id parsed from the location', () => { render( - + } /> - + ); expect(screen.queryByText('abc')).not.toBeNull(); }); it('should return the record id parsed from the location even if it is falsy', () => { render( - + } /> - + ); expect(screen.queryByText('0')).not.toBeNull(); }); diff --git a/packages/ra-core/src/form/useUnique.stories.tsx b/packages/ra-core/src/form/useUnique.stories.tsx index b187ea5366b..cfa57fc92db 100644 --- a/packages/ra-core/src/form/useUnique.stories.tsx +++ b/packages/ra-core/src/form/useUnique.stories.tsx @@ -14,8 +14,8 @@ import { mergeTranslations, useUnique, } from '..'; -import { createMemoryHistory } from 'history'; import { QueryClient } from '@tanstack/react-query'; +import { TestMemoryRouter } from '../routing'; export default { title: 'ra-core/form/useUnique', @@ -82,14 +82,15 @@ const i18nProvider = polyglotI18nProvider(() => const Wrapper = ({ children, dataProvider = defaultDataProvider }) => { return ( - - {children} - + + + {children} + + ); }; diff --git a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx index 9fca350ca3b..932dfcf540a 100644 --- a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx +++ b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx @@ -2,13 +2,8 @@ import * as React from 'react'; import expect from 'expect'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useForm, useFormContext, FormProvider } from 'react-hook-form'; -import { - MemoryRouter, - Route, - Routes, - useNavigate, - useParams, -} from 'react-router-dom'; +import { Route, Routes, useNavigate, useParams } from 'react-router-dom'; +import { TestMemoryRouter } from '../routing'; import { useWarnWhenUnsavedChanges } from './useWarnWhenUnsavedChanges'; @@ -60,7 +55,7 @@ const FormUnderTest = () => { const save = () => new Promise(resolve => { setTimeout(() => navigate('/submitted'), 100); - resolve(); + resolve(null); }); const onSubmit = () => { save(); @@ -73,7 +68,7 @@ const FormUnderTest = () => { }; const App = ({ initialEntries = ['/form'] }) => ( - + } /> Show} /> @@ -81,7 +76,7 @@ const App = ({ initialEntries = ['/form'] }) => ( Submitted} /> Somewhere} /> - + ); describe('useWarnWhenUnsavedChanges', () => { diff --git a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx index 9758d3e6d50..35fbae21679 100644 --- a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx +++ b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx @@ -1,7 +1,6 @@ -import { useContext, useEffect, useRef } from 'react'; -import { useFormState, Control } from 'react-hook-form'; -import { UNSAFE_NavigationContext, useLocation } from 'react-router-dom'; -import { History, Transition } from 'history'; +import { useEffect, useState } from 'react'; +import { Control, useFormState } from 'react-hook-form'; +import { useBlocker } from 'react-router-dom'; import { useTranslate } from '../i18n'; /** @@ -14,66 +13,84 @@ export const useWarnWhenUnsavedChanges = ( formRootPathname?: string, control?: Control ) => { - // react-router v6 does not yet provide a way to block navigation - // This is planned for a future release - // See https://github.com/remix-run/react-router/issues/8139 - const navigator = useContext(UNSAFE_NavigationContext).navigator as History; - const location = useLocation(); const translate = useTranslate(); - const { isSubmitSuccessful, isSubmitting, dirtyFields } = useFormState( + const { isSubmitSuccessful, dirtyFields } = useFormState( control ? { control } : undefined ); const isDirty = Object.keys(dirtyFields).length > 0; - const initialLocation = useRef(formRootPathname || location.pathname); + const [shouldNotify, setShouldNotify] = useState(false); + + const shouldNotBlock = !enable || !isDirty || isSubmitSuccessful; + + const blocker = useBlocker(({ currentLocation, nextLocation }) => { + if (shouldNotBlock) return false; + + // Also check if the new location is inside the form + const initialLocation = formRootPathname || currentLocation.pathname; + const newLocationIsInsideCurrentLocation = nextLocation.pathname.startsWith( + initialLocation + ); + const newLocationIsShowView = nextLocation.pathname.startsWith( + `${initialLocation}/show` + ); + const newLocationIsInsideForm = + newLocationIsInsideCurrentLocation && !newLocationIsShowView; + if (newLocationIsInsideForm) return false; + + return true; + }); useEffect(() => { - if (!enable || !isDirty) return; - if (!navigator.block) { - if (process.env.NODE_ENV !== 'production') { - console.warn( - 'warnWhenUnsavedChanged is not compatible with react-router >= 6.4. If you need this feature, please downgrade react-router to 6.3.0' - ); + if (blocker.state === 'blocked') { + // Corner case: the blocker might be triggered by a redirect in the onSuccess side effect, + // happening during the same tick the form is reset after a successful save. + // In that case, the blocker will block but shouldNotBlock will be true one tick after. + // If we are in that case, we can proceed immediately. + if (shouldNotBlock) { + blocker.proceed(); + return; } - return; + + setShouldNotify(true); } + // This effect should only run when the blocker state changes, not when shouldNotBlock changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [blocker.state]); - let unblock = navigator.block((tx: Transition) => { - const newLocationIsInsideCurrentLocation = tx.location.pathname.startsWith( - initialLocation.current - ); - const newLocationIsShowView = tx.location.pathname.startsWith( - `${initialLocation.current}/show` + useEffect(() => { + if (shouldNotify) { + const shouldProceed = window.confirm( + translate('ra.message.unsaved_changes') ); - const newLocationIsInsideForm = - newLocationIsInsideCurrentLocation && !newLocationIsShowView; - - if ( - !isSubmitting && - (newLocationIsInsideForm || - isSubmitSuccessful || - window.confirm(translate('ra.message.unsaved_changes'))) - ) { - unblock(); - tx.retry(); + if (shouldProceed) { + blocker.proceed(); } else { - if (isSubmitting) { - // Retry the transition (possibly several times) until the form is no longer submitting. - // The value of 100ms is arbitrary, it allows to give some time between retries. - setTimeout(() => { - tx.retry(); - }, 100); - } + blocker.reset(); } - }); + } + setShouldNotify(false); + // Can't use blocker in the dependency array because it is not stable across rerenders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldNotify, translate]); + + // This effect handles document navigation, e.g. closing the tab + useEffect(() => { + const beforeunload = (e: BeforeUnloadEvent) => { + // Invoking event.preventDefault() will trigger a warning dialog when the user closes or navigates the tab + // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#examples + e.preventDefault(); + // Included for legacy support, e.g. Chrome/Edge < 119 + e.returnValue = true; + }; + + if (shouldNotBlock) { + return; + } + + window.addEventListener('beforeunload', beforeunload); - return unblock; - }, [ - enable, - location, - navigator, - isDirty, - isSubmitting, - isSubmitSuccessful, - translate, - ]); + return () => { + window.removeEventListener('beforeunload', beforeunload); + }; + }, [shouldNotBlock]); }; diff --git a/packages/ra-core/src/routing/AdminRouter.tsx b/packages/ra-core/src/routing/AdminRouter.tsx index 6c0bb0f1bbb..fbf31d6e5fe 100644 --- a/packages/ra-core/src/routing/AdminRouter.tsx +++ b/packages/ra-core/src/routing/AdminRouter.tsx @@ -1,49 +1,49 @@ import * as React from 'react'; -import { ReactNode, useMemo } from 'react'; -import { useInRouterContext } from 'react-router-dom'; -import { createHashHistory, History } from 'history'; +import { ReactNode } from 'react'; +import { + useInRouterContext, + createHashRouter, + RouterProvider, +} from 'react-router-dom'; -import { HistoryRouter, HistoryRouterProps } from './HistoryRouter'; import { BasenameContextProvider } from './BasenameContextProvider'; /** * Creates a react-router Router unless the app is already inside existing router. * Also creates a BasenameContext with the basename prop */ -export const AdminRouter = ({ - history, - basename = '', - children, -}: AdminRouterProps) => { +export const AdminRouter = ({ basename = '', children }: AdminRouterProps) => { const isInRouter = useInRouterContext(); const Router = isInRouter ? DummyRouter : InternalRouter; return ( - - {children} - + {children} ); }; export interface AdminRouterProps { - history?: History; basename?: string; children: React.ReactNode; } -const DummyRouter = ({ children }: { children: ReactNode }) => <>{children}; +const DummyRouter = ({ + children, +}: { + children: ReactNode; + basename?: string; +}) => <>{children}; const InternalRouter = ({ children, - history, + basename, }: { - history?: History; -} & Omit) => { - const finalHistory = useMemo(() => history || createHashHistory(), [ - history, - ]); - - return {children}; + children: ReactNode; + basename?: string; +}) => { + const router = createHashRouter([{ path: '*', element: <>{children} }], { + basename, + }); + return ; }; diff --git a/packages/ra-core/src/routing/HistoryRouter.tsx b/packages/ra-core/src/routing/HistoryRouter.tsx deleted file mode 100644 index a05c66ca90f..00000000000 --- a/packages/ra-core/src/routing/HistoryRouter.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from 'react'; -import { useLayoutEffect, useState } from 'react'; -import { History } from 'history'; -import { Router } from 'react-router'; - -/** - * A router that accepts a custom history. - * To remove once https://github.com/remix-run/react-router/pull/7586 is merged. - */ -export function HistoryRouter({ - basename, - children, - history, -}: HistoryRouterProps) { - const [state, setState] = useState({ - action: history.action, - location: history.location, - }); - - useLayoutEffect(() => history.listen(setState), [history]); - - return ( - - ); -} - -export interface HistoryRouterProps { - basename?: string; - children?: React.ReactNode; - history: History; -} diff --git a/packages/ra-core/src/routing/TestMemoryRouter.tsx b/packages/ra-core/src/routing/TestMemoryRouter.tsx new file mode 100644 index 00000000000..8056a0b6423 --- /dev/null +++ b/packages/ra-core/src/routing/TestMemoryRouter.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { + createMemoryRouter, + RouterProvider, + useLocation, + Location, + useNavigate, + NavigateFunction, +} from 'react-router-dom'; +import type { InitialEntry } from '@remix-run/router'; + +const UseLocation = ({ + locationCallback, +}: { + locationCallback: (l: Location) => void; +}) => { + const location = useLocation(); + locationCallback(location); + return null; +}; + +const UseNavigate = ({ + navigateCallback, +}: { + navigateCallback: (n: NavigateFunction) => void; +}) => { + const navigate = useNavigate(); + navigateCallback(navigate); + return null; +}; + +/** + * Wrapper around react-router's `createMemoryRouter` to be used in test components. + * + * It is similar to `MemoryRouter` but it supports + * [data APIs](https://reactrouter.com/en/main/routers/picking-a-router#data-apis). + * + * Additionally, it provides + * - a `locationCallback` prop to get the location in the test + * - a `navigateCallback` prop to be able to navigate in the test + */ +export const TestMemoryRouter = ({ + children, + locationCallback, + navigateCallback, + ...rest +}: { + children: React.ReactNode; + locationCallback?: (l: Location) => void; + navigateCallback?: (n: NavigateFunction) => void; + basename?: string; + initialEntries?: InitialEntry[]; + initialIndex?: number; +}) => { + const router = createMemoryRouter( + [ + { + path: '*', + element: ( + <> + {children} + {locationCallback && ( + + )} + {navigateCallback && ( + + )} + + ), + }, + ], + { ...rest } + ); + return ; +}; diff --git a/packages/ra-core/src/routing/index.ts b/packages/ra-core/src/routing/index.ts index f4f8408b4c6..8eba385dd65 100644 --- a/packages/ra-core/src/routing/index.ts +++ b/packages/ra-core/src/routing/index.ts @@ -7,3 +7,4 @@ export * from './useCreatePath'; export * from './useRedirect'; export * from './useScrollToTop'; export * from './types'; +export * from './TestMemoryRouter'; diff --git a/packages/ra-core/src/routing/useCreatePath.spec.tsx b/packages/ra-core/src/routing/useCreatePath.spec.tsx index 5059f5b0e19..8496d4612a5 100644 --- a/packages/ra-core/src/routing/useCreatePath.spec.tsx +++ b/packages/ra-core/src/routing/useCreatePath.spec.tsx @@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react'; import { CreatePathType, useCreatePath } from './useCreatePath'; import { AtRoot, SubPath } from './useCreatePath.stories'; import { Identifier } from '../types'; +import { TestMemoryRouter } from './TestMemoryRouter'; describe('useCreatePath', () => { beforeEach(() => { @@ -50,7 +51,11 @@ describe('useCreatePath', () => { }); it('creates valid links when used without a basename', async () => { - render(); + render( + + + + ); await screen.findByText('Home'); screen.getByText('Post list').click(); await screen.findByText('Posts'); @@ -60,7 +65,11 @@ describe('useCreatePath', () => { }); it('creates valid links when used with a basename', async () => { - render(); + render( + + + + ); await screen.findByText('Main'); screen.getByText('Go to admin').click(); await screen.findByText('Home'); diff --git a/packages/ra-core/src/routing/useCreatePath.stories.tsx b/packages/ra-core/src/routing/useCreatePath.stories.tsx index 5ca8d926b82..9f0c174d9b3 100644 --- a/packages/ra-core/src/routing/useCreatePath.stories.tsx +++ b/packages/ra-core/src/routing/useCreatePath.stories.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; import { Link, Routes, Route } from 'react-router-dom'; -import { createHashHistory } from 'history'; -import { HistoryRouter } from './HistoryRouter'; import { BasenameContextProvider } from './BasenameContextProvider'; import { useBasename } from './useBasename'; import { useCreatePath } from './useCreatePath'; @@ -65,42 +63,38 @@ const PostDetail = () => { ); }; -export const AtRoot = (props, context) => ( - - - } /> - } /> - } /> - - +export const AtRoot = () => ( + + } /> + } /> + } /> + ); -export const SubPath = (props, context) => ( - - - -

Main

-
- Go to admin -
- - } - /> - - - } /> - } /> - } /> - - - } - /> -
-
+export const SubPath = () => ( + + +

Main

+
+ Go to admin +
+ + } + /> + + + } /> + } /> + } /> + + + } + /> +
); diff --git a/packages/ra-core/src/routing/useRedirect.spec.tsx b/packages/ra-core/src/routing/useRedirect.spec.tsx index fbbcdedf984..62cc15aec6b 100644 --- a/packages/ra-core/src/routing/useRedirect.spec.tsx +++ b/packages/ra-core/src/routing/useRedirect.spec.tsx @@ -3,12 +3,12 @@ import { useEffect } from 'react'; import expect from 'expect'; import { render, screen, waitFor } from '@testing-library/react'; import { Routes, Route, useLocation } from 'react-router-dom'; -import { createMemoryHistory } from 'history'; - import { CoreAdminContext } from '../core'; + import { RedirectionSideEffect, useRedirect } from './useRedirect'; import { testDataProvider } from '../dataProvider'; import { Identifier, RaRecord } from '../types'; +import { TestMemoryRouter } from './TestMemoryRouter'; const Redirect = ({ redirectTo, @@ -48,18 +48,17 @@ const Component = () => { describe('useRedirect', () => { it('should redirect to the path with query string', async () => { render( - - - } - /> - } /> - - + + + + } + /> + } /> + + + ); await waitFor(() => { expect(screen.queryByDisplayValue('?bar=baz')).not.toBeNull(); @@ -67,23 +66,22 @@ describe('useRedirect', () => { }); it('should redirect to the path with state', async () => { render( - - - - } - /> - } /> - - + + + + + } + /> + } /> + + + ); await waitFor(() => { expect( @@ -101,12 +99,11 @@ describe('useRedirect', () => { // @ts-ignore window.location = { href: '' }; render( - - - + + + + + ); expect(window.location.href).toBe('https://google.com'); window.location = oldLocation; diff --git a/packages/ra-core/src/routing/useRedirect.ts b/packages/ra-core/src/routing/useRedirect.ts index 78d9068794c..7cffa85271d 100644 --- a/packages/ra-core/src/routing/useRedirect.ts +++ b/packages/ra-core/src/routing/useRedirect.ts @@ -1,8 +1,7 @@ import { useCallback } from 'react'; import { useNavigate, To } from 'react-router-dom'; -import { parsePath } from 'history'; - import { Identifier, RaRecord } from '../types'; + import { useBasename } from './useBasename'; import { CreatePathType, useCreatePath } from './useCreatePath'; @@ -56,14 +55,9 @@ export const useRedirect = () => { pathname: `${basename}/${target.pathname}`, ...target, }; - navigate( - typeof absoluteTarget === 'string' - ? parsePath(absoluteTarget) - : absoluteTarget, - { - state: { _scrollToTop: true, ...state }, - } - ); + navigate(absoluteTarget, { + state: { _scrollToTop: true, ...state }, + }); return; } else if ( typeof redirectTo === 'string' && diff --git a/packages/ra-core/src/storybook/FakeBrowser.tsx b/packages/ra-core/src/storybook/FakeBrowser.tsx index d141fda68d8..96a8fb4d083 100644 --- a/packages/ra-core/src/storybook/FakeBrowser.tsx +++ b/packages/ra-core/src/storybook/FakeBrowser.tsx @@ -1,12 +1,7 @@ import * as React from 'react'; -import { ReactNode, useEffect, useState } from 'react'; -import { - createMemoryHistory, - History, - Location, - createPath, - parsePath, -} from 'history'; +import { ReactNode } from 'react'; +import { TestMemoryRouter } from '../routing'; +import { useLocation, useNavigate } from 'react-router-dom'; /** * This is a storybook decorator that wrap the story inside a fake browser. @@ -21,33 +16,18 @@ import { * initialEntries: ['/authenticated'], * }, * }; - * - * const MyStory = (args, context) => ( - * // Don't forget to pass the history to the Admin component so that - * // user changes from the fake browser address bar can impact the application - * - * - * ); */ export const FakeBrowserDecorator = (Story, context) => { - const history = createMemoryHistory({ - initialEntries: context.parameters.initialEntries, - }); - return ( - - - + + + + + ); }; -const Browser = ({ - children, - history, -}: { - children: ReactNode; - history: History; -}) => { +const Browser = ({ children }: { children: ReactNode }) => { return ( <>