diff --git a/packages/ra-core/src/auth/WithPermissions.stories.tsx b/packages/ra-core/src/auth/WithPermissions.stories.tsx new file mode 100644 index 00000000000..9cb2221584a --- /dev/null +++ b/packages/ra-core/src/auth/WithPermissions.stories.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { AuthProvider } from '../types'; +import { CoreAdminContext } from '../core'; +import { TestMemoryRouter, WithPermissions } from '..'; + +export default { + title: 'ra-core/auth/WithPermissions', +}; + +export const NoAuthProvider = () => ( + + + + + +); + +export const NoAuthProviderGetPermissions = ({ + loading = () =>

Loading...

, +}: { + loading: React.ComponentType; +}) => ( + + Promise.reject('bad method'), + logout: () => Promise.reject('bad method'), + checkAuth: () => + new Promise(resolve => setTimeout(resolve, 300)), + checkError: () => Promise.reject('bad method'), + }} + > + + + +); + +export const WithAuthProviderAndGetPermissions = ({ + loading = () =>

Loading...

, + authProvider = { + login: () => Promise.reject('bad method'), + logout: () => Promise.reject('bad method'), + checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), + checkError: () => Promise.reject('bad method'), + getPermissions: () => + new Promise(resolve => setTimeout(resolve, 300, 'admin')), + }, +}: { + loading: React.ComponentType; + authProvider?: AuthProvider; +}) => ( + + + + + +); + +const StateInspector = ({ permissions }: { permissions: any }) => ( +
+ {permissions === 'admin' ? ( +

Sensitive data

+ ) : ( +

Non sensitive data

+ )} +
+); diff --git a/packages/ra-core/src/auth/WithPermissions.tsx b/packages/ra-core/src/auth/WithPermissions.tsx index f09680b99df..239edaf99e7 100644 --- a/packages/ra-core/src/auth/WithPermissions.tsx +++ b/packages/ra-core/src/auth/WithPermissions.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { Children, ReactElement, ComponentType, createElement } from 'react'; import { Location } from 'react-router-dom'; @@ -17,6 +18,7 @@ export interface WithPermissionsProps { authParams?: object; children?: WithPermissionsChildren; component?: ComponentType; + loading?: ComponentType; location?: Location; render?: WithPermissionsChildren; staticContext?: object; @@ -60,8 +62,15 @@ const isEmptyChildren = children => Children.count(children) === 0; * ); */ const WithPermissions = (props: WithPermissionsProps) => { - const { authParams, children, render, component, staticContext, ...rest } = - props; + const { + authParams, + children, + render, + component, + loading: Loading = null, + staticContext, + ...rest + } = props; warning( (render && children && !isEmptyChildren(children)) || (render && component) || @@ -70,11 +79,15 @@ const WithPermissions = (props: WithPermissionsProps) => { ); const { isPending: isAuthenticationPending } = useAuthenticated(authParams); - const { permissions, isPending } = usePermissions(authParams, { - enabled: !isAuthenticationPending, - }); - if (isPending) { - return null; + const { permissions, isPending: isPendingPermissions } = usePermissions( + authParams, + { + enabled: !isAuthenticationPending, + } + ); + // We must check both pending states here as if the authProvider does not implement getPermissions, isPendingPermissions will always be false + if (isAuthenticationPending || isPendingPermissions) { + return Loading ? : null; } if (component) { diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index 211fc0febb2..9c90e545e5c 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -20,6 +20,7 @@ export * from './useCanAccessCallback'; export * from './useCheckAuth'; export * from './useGetIdentity'; export * from './useHandleAuthCallback'; +export * from './useIsAuthPending'; export * from './useRequireAccess'; export * from './addRefreshAuthToAuthProvider'; export * from './addRefreshAuthToDataProvider'; diff --git a/packages/ra-core/src/auth/useIsAuthPending.ts b/packages/ra-core/src/auth/useIsAuthPending.ts new file mode 100644 index 00000000000..c424084dfcd --- /dev/null +++ b/packages/ra-core/src/auth/useIsAuthPending.ts @@ -0,0 +1,43 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useResourceContext } from '../core'; +import { HintedString } from '../types'; +import useAuthProvider from './useAuthProvider'; + +/** + * A hook that returns true if the authProvider is currently checking the authentication status or the user's access rights. + * @param params + * @param params.action The action to check access for + * @param params.resource The resource to check access for (optional). Defaults to the resource of the current ResourceContext. + * @returns {boolean} true if the authProvider is currently checking the authentication status or the user's access rights, false otherwise. + */ +export const useIsAuthPending = (params: UseIsAuthPendingParams) => { + const { action, ...props } = params; + const queryClient = useQueryClient(); + const authProvider = useAuthProvider(); + const resource = useResourceContext(props); + + if (!authProvider) { + return false; + } + + const authQueryState = queryClient.getQueryState(['auth', 'checkAuth', {}]); + const canAccessQueryState = queryClient.getQueryState([ + 'auth', + 'canAccess', + { action, resource }, + ]); + + if ( + authQueryState?.status === 'pending' || + (authProvider.canAccess && canAccessQueryState?.status === 'pending') + ) { + return true; + } + + return false; +}; + +export type UseIsAuthPendingParams = { + resource?: string; + action: HintedString<'list' | 'create' | 'edit' | 'show' | 'delete'>; +}; diff --git a/packages/ra-core/src/controller/create/CreateBase.spec.tsx b/packages/ra-core/src/controller/create/CreateBase.spec.tsx index a49c3d1857e..80b35dc8e32 100644 --- a/packages/ra-core/src/controller/create/CreateBase.spec.tsx +++ b/packages/ra-core/src/controller/create/CreateBase.spec.tsx @@ -1,47 +1,25 @@ import * as React from 'react'; import expect from 'expect'; -import { useEffect } from 'react'; -import { screen, render, waitFor } from '@testing-library/react'; +import { screen, render, waitFor, fireEvent } from '@testing-library/react'; -import { CoreAdminContext } from '../../core'; import { testDataProvider } from '../../dataProvider'; -import { useSaveContext } from '../saveContext'; -import { CreateBase } from './CreateBase'; +import { + AccessControl, + NoAuthProvider, + WithAuthProviderNoAccessControl, +} from './CreateBase.stories'; describe('CreateBase', () => { - const defaultProps = { - hasCreate: true, - hasEdit: true, - hasList: true, - hasShow: true, - id: 12, - resource: 'posts', - debounce: 200, - }; - it('should give access to the save function', async () => { const dataProvider = testDataProvider({ + // @ts-ignore create: jest.fn((_, { data }) => Promise.resolve({ data: { id: 1, ...data } }) ), }); - const Child = () => { - const saveContext = useSaveContext(); - - useEffect(() => { - saveContext.save({ test: 'test' }); - }, []); // eslint-disable-line - - return null; - }; - render( - - - - - - ); + render(); + fireEvent.click(screen.getByText('save')); await waitFor(() => { expect(dataProvider.create).toHaveBeenCalledWith('posts', { @@ -52,30 +30,21 @@ describe('CreateBase', () => { it('should allow to override the onSuccess function', async () => { const dataProvider = testDataProvider({ + // @ts-ignore create: jest.fn((_, { data }) => Promise.resolve({ data: { id: 1, ...data } }) ), }); const onSuccess = jest.fn(); - const Child = () => { - const saveContext = useSaveContext(); - - const handleClick = () => { - saveContext.save({ test: 'test' }); - }; - - return ; +}; diff --git a/packages/ra-core/src/controller/create/CreateBase.tsx b/packages/ra-core/src/controller/create/CreateBase.tsx index 4ea4d250406..69d6f9569ed 100644 --- a/packages/ra-core/src/controller/create/CreateBase.tsx +++ b/packages/ra-core/src/controller/create/CreateBase.tsx @@ -6,7 +6,8 @@ import { } from './useCreateController'; import { CreateContextProvider } from './CreateContextProvider'; import { Identifier, RaRecord } from '../../types'; -import { ResourceContextProvider } from '../../core'; +import { OptionalResourceContextProvider } from '../../core'; +import { useIsAuthPending } from '../../auth'; /** * Call useCreateController and put the value in a CreateContext @@ -40,28 +41,46 @@ import { ResourceContextProvider } from '../../core'; export const CreateBase = < RecordType extends Omit = any, ResultRecordType extends RaRecord = RecordType & { id: Identifier }, + MutationOptionsError = Error, >({ children, + loading = null, ...props -}: CreateControllerProps & { - children: ReactNode; -}) => { +}: CreateBaseProps) => { const controllerProps = useCreateController< RecordType, - Error, + MutationOptionsError, ResultRecordType >(props); - const body = ( - - {children} - - ); - return props.resource ? ( - // support resource override via props - - {body} - - ) : ( - body + + const isAuthPending = useIsAuthPending({ + resource: controllerProps.resource, + action: 'create', + }); + + if (isAuthPending && !props.disableAuthentication) { + return loading; + } + + return ( + // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided + + + {children} + + ); }; + +export interface CreateBaseProps< + RecordType extends Omit = any, + ResultRecordType extends RaRecord = RecordType & { id: Identifier }, + MutationOptionsError = Error, +> extends CreateControllerProps< + RecordType, + MutationOptionsError, + ResultRecordType + > { + children: ReactNode; + loading?: ReactNode; +} diff --git a/packages/ra-core/src/controller/edit/EditBase.spec.tsx b/packages/ra-core/src/controller/edit/EditBase.spec.tsx index 2ee98b2f57b..b4d22774fb0 100644 --- a/packages/ra-core/src/controller/edit/EditBase.spec.tsx +++ b/packages/ra-core/src/controller/edit/EditBase.spec.tsx @@ -1,65 +1,40 @@ import * as React from 'react'; import expect from 'expect'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { EditBase } from './EditBase'; -import { CoreAdminContext } from '../../core'; import { testDataProvider } from '../../dataProvider'; -import { useSaveContext } from '../saveContext'; -import { useRecordContext } from '../'; +import { + AccessControl, + NoAuthProvider, + WithAuthProviderNoAccessControl, +} from './EditBase.stories'; describe('EditBase', () => { - const defaultProps = { - hasCreate: true, - hasEdit: true, - hasList: true, - hasShow: true, - id: 12, - resource: 'posts', - debounce: 200, - }; - it('should give access to the save function', async () => { const dataProvider = testDataProvider({ + // @ts-ignore getOne: () => - // @ts-ignore Promise.resolve({ data: { id: 12, test: 'previous' } }), update: jest.fn((_, { id, data, previousData }) => Promise.resolve({ data: { id, ...previousData, ...data } }) ), }); - const Child = () => { - const saveContext = useSaveContext(); - const record = useRecordContext(); - - const handleClick = () => { - saveContext.save({ test: 'test' }); - }; - - return ( - <> -

{record?.test}

- + + ); +}; diff --git a/packages/ra-core/src/controller/edit/EditBase.tsx b/packages/ra-core/src/controller/edit/EditBase.tsx index 8f76073a94f..97cd99d717a 100644 --- a/packages/ra-core/src/controller/edit/EditBase.tsx +++ b/packages/ra-core/src/controller/edit/EditBase.tsx @@ -4,7 +4,8 @@ import { ReactNode } from 'react'; import { RaRecord } from '../../types'; import { useEditController, EditControllerProps } from './useEditController'; import { EditContextProvider } from './EditContextProvider'; -import { ResourceContextProvider } from '../../core'; +import { OptionalResourceContextProvider } from '../../core'; +import { useIsAuthPending } from '../../auth'; /** * Call useEditController and put the value in a EditContext @@ -35,22 +36,36 @@ import { ResourceContextProvider } from '../../core'; * * ); */ -export const EditBase = ({ +export const EditBase = ({ children, + loading = null, ...props -}: { children: ReactNode } & EditControllerProps) => { - const controllerProps = useEditController(props); - const body = ( - - {children} - - ); - return props.resource ? ( - // support resource override via props - - {body} - - ) : ( - body +}: EditBaseProps) => { + const controllerProps = useEditController(props); + + const isAuthPending = useIsAuthPending({ + resource: controllerProps.resource, + action: 'edit', + }); + + if (isAuthPending && !props.disableAuthentication) { + return loading; + } + + return ( + // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided + + + {children} + + ); }; + +export interface EditBaseProps< + RecordType extends RaRecord = RaRecord, + ErrorType = Error, +> extends EditControllerProps { + children: ReactNode; + loading?: ReactNode; +} diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx index 97511c09e48..e9af0330a7e 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx @@ -1,6 +1,12 @@ import * as React from 'react'; -import { Basic } from './InfiniteListBase.stories'; +import { + AccessControl, + Basic, + NoAuthProvider, + WithAuthProviderNoAccessControl, +} from './InfiniteListBase.stories'; import { render, screen, waitFor } from '@testing-library/react'; +import { testDataProvider } from '../../dataProvider'; describe('InfiniteListBase', () => { it('should fetch a list of records on mount, put it in a ListContext, and render its children', async () => { @@ -26,4 +32,84 @@ describe('InfiniteListBase', () => { // first page is still visible await screen.findByText('The Lord of the Rings'); // #5 }); + it('should load data immediately if authProvider is not provided', async () => { + const dataProvider = testDataProvider({ + // @ts-ignore + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 }) + ), + }); + render(); + expect(dataProvider.getList).toHaveBeenCalled(); + await screen.findByText('Hello'); + }); + it('should wait for the authentication resolution before loading data', async () => { + let resolveAuth: () => void; + const authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkError: () => Promise.resolve(), + checkAuth: () => + new Promise(resolve => { + resolveAuth = resolve; + }), + }; + const dataProvider = testDataProvider({ + // @ts-ignore + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 }) + ), + }); + render( + + ); + expect(dataProvider.getList).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + resolveAuth!(); + await screen.findByText('Hello'); + }); + it('should wait for both the authentication and authorization resolution before loading data', async () => { + let resolveAuth: () => void; + let resolveCanAccess: (value: boolean) => void; + const authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkError: () => Promise.resolve(), + checkAuth: () => + new Promise(resolve => { + resolveAuth = resolve; + }), + canAccess: jest.fn( + () => + new Promise(resolve => { + resolveCanAccess = resolve; + }) + ), + }; + const dataProvider = testDataProvider({ + // @ts-ignore + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 }) + ), + }); + render( + + ); + expect(dataProvider.getList).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + resolveAuth!(); + expect(dataProvider.getList).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + await waitFor(() => { + expect(authProvider.canAccess).toHaveBeenCalled(); + }); + resolveCanAccess!(true); + await screen.findByText('Hello'); + }); }); diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx index 94010a21251..dbd5d14cf2a 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx @@ -5,6 +5,7 @@ import { InfiniteListBase } from './InfiniteListBase'; import { CoreAdminContext } from '../../core'; import { useListContext } from './useListContext'; import { useInfinitePaginationContext } from './useInfinitePaginationContext'; +import { AuthProvider, DataProvider } from '../..'; export default { title: 'ra-core/controller/list/InfiniteListBase', @@ -40,7 +41,7 @@ const data = { ], }; -const dataProvider = fakeRestProvider(data, undefined, 300); +const defaultDataProvider = fakeRestProvider(data, undefined, 300); const BookListView = () => { const { data, isPending, sort, setSort, filterValues, setFilters } = @@ -63,7 +64,7 @@ const BookListView = () => {
    - {data.map((record: any) => ( + {data?.map((record: any) => (
  • {record.title}
  • ))}
@@ -103,10 +104,69 @@ const InfinitePagination = () => { }; export const Basic = () => ( - + ); + +export const NoAuthProvider = ({ + dataProvider = defaultDataProvider, +}: { + dataProvider?: DataProvider; +}) => ( + + + + + +); + +export const WithAuthProviderNoAccessControl = ({ + authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), + checkError: () => Promise.resolve(), + }, + dataProvider = defaultDataProvider, +}: { + authProvider?: AuthProvider; + dataProvider?: DataProvider; +}) => ( + + Authentication loading...} + > + + + +); + +export const AccessControl = ({ + authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), + checkError: () => Promise.resolve(), + canAccess: () => new Promise(resolve => setTimeout(resolve, 300, true)), + }, + dataProvider = defaultDataProvider, +}: { + authProvider?: AuthProvider; + dataProvider?: DataProvider; +}) => ( + + Authentication loading...} + > + + + +); diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.tsx index d5f4a07862c..6decf7ae8ab 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.tsx @@ -4,10 +4,11 @@ import { useInfiniteListController, InfiniteListControllerProps, } from './useInfiniteListController'; -import { ResourceContextProvider } from '../../core'; +import { OptionalResourceContextProvider } from '../../core'; import { RaRecord } from '../../types'; import { ListContextProvider } from './ListContextProvider'; import { InfinitePaginationContext } from './InfinitePaginationContext'; +import { useIsAuthPending } from '../../auth'; /** * Call useInfiniteListController and put the value in a ListContext @@ -45,11 +46,22 @@ import { InfinitePaginationContext } from './InfinitePaginationContext'; */ export const InfiniteListBase = ({ children, + loading = null, ...props -}: InfiniteListControllerProps & { children: ReactNode }) => { +}: InfiniteListBaseProps) => { const controllerProps = useInfiniteListController(props); + const isAuthPending = useIsAuthPending({ + resource: controllerProps.resource, + action: 'list', + }); + + if (isAuthPending && !props.disableAuthentication) { + return loading; + } + return ( - + // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided + ({ {children} - + ); }; + +export interface InfiniteListBaseProps + extends InfiniteListControllerProps { + children: ReactNode; + loading?: ReactNode; +} diff --git a/packages/ra-core/src/controller/list/ListBase.spec.tsx b/packages/ra-core/src/controller/list/ListBase.spec.tsx new file mode 100644 index 00000000000..64d73984c66 --- /dev/null +++ b/packages/ra-core/src/controller/list/ListBase.spec.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { + AccessControl, + NoAuthProvider, + WithAuthProviderNoAccessControl, +} from './ListBase.stories'; +import { testDataProvider } from '../../dataProvider'; + +describe('ListBase', () => { + it('should load data immediately if authProvider is not provided', async () => { + const dataProvider = testDataProvider({ + // @ts-ignore + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 }) + ), + }); + render(); + expect(dataProvider.getList).toHaveBeenCalled(); + await screen.findByText('Hello'); + }); + it('should wait for the authentication resolution before loading data', async () => { + let resolveAuth: () => void; + const authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkError: () => Promise.resolve(), + checkAuth: () => + new Promise(resolve => { + resolveAuth = resolve; + }), + }; + const dataProvider = testDataProvider({ + // @ts-ignore + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 }) + ), + }); + render( + + ); + expect(dataProvider.getList).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + resolveAuth!(); + await screen.findByText('Hello'); + }); + it('should wait for both the authentication and authorization resolution before loading data', async () => { + let resolveAuth: () => void; + let resolveCanAccess: (value: boolean) => void; + const authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkError: () => Promise.resolve(), + checkAuth: () => + new Promise(resolve => { + resolveAuth = resolve; + }), + canAccess: jest.fn( + () => + new Promise(resolve => { + resolveCanAccess = resolve; + }) + ), + }; + const dataProvider = testDataProvider({ + // @ts-ignore + getList: jest.fn(() => + Promise.resolve({ data: [{ id: 1, title: 'Hello' }], total: 1 }) + ), + }); + render( + + ); + expect(dataProvider.getList).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + resolveAuth!(); + expect(dataProvider.getList).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + await waitFor(() => { + expect(authProvider.canAccess).toHaveBeenCalled(); + }); + resolveCanAccess!(true); + await screen.findByText('Hello'); + }); +}); diff --git a/packages/ra-core/src/controller/list/ListBase.stories.tsx b/packages/ra-core/src/controller/list/ListBase.stories.tsx index 9bc87898720..873750ad25c 100644 --- a/packages/ra-core/src/controller/list/ListBase.stories.tsx +++ b/packages/ra-core/src/controller/list/ListBase.stories.tsx @@ -4,6 +4,7 @@ import fakeRestProvider from 'ra-data-fakerest'; import { ListBase } from './ListBase'; import { CoreAdminContext } from '../../core'; import { useListContext } from './useListContext'; +import { AuthProvider, DataProvider } from '../..'; export default { title: 'ra-core/controller/list/ListBase', @@ -39,7 +40,7 @@ const data = { ], }; -const dataProvider = fakeRestProvider(data, true, 300); +const defaultDataProvider = fakeRestProvider(data, true, 300); const BookListView = () => { const { @@ -111,7 +112,11 @@ const BookListView = () => { ); }; -export const SetParams = () => ( +export const NoAuthProvider = ({ + dataProvider = defaultDataProvider, +}: { + dataProvider?: DataProvider; +}) => ( @@ -119,6 +124,61 @@ export const SetParams = () => ( ); +export const WithAuthProviderNoAccessControl = ({ + authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), + checkError: () => Promise.resolve(), + }, + dataProvider = defaultDataProvider, +}: { + authProvider?: AuthProvider; + dataProvider?: DataProvider; +}) => ( + + Authentication loading...} + > + + + +); + +export const AccessControl = ({ + authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), + checkError: () => Promise.resolve(), + canAccess: () => new Promise(resolve => setTimeout(resolve, 300, true)), + }, + dataProvider = defaultDataProvider, +}: { + authProvider?: AuthProvider; + dataProvider?: DataProvider; +}) => ( + + Authentication loading...} + > + + + +); + +export const SetParams = () => ( + + + + + +); + const ListMetadataInspector = () => { const listContext = useListContext(); return ( @@ -132,9 +192,12 @@ const ListMetadataInspector = () => { export const WithResponseMetadata = () => ( { - const result = await dataProvider.getList(resource, params); + const result = await defaultDataProvider.getList( + resource, + params + ); return { ...result, meta: { diff --git a/packages/ra-core/src/controller/list/ListBase.tsx b/packages/ra-core/src/controller/list/ListBase.tsx index c95a62a3dc7..e5d14f4d1b4 100644 --- a/packages/ra-core/src/controller/list/ListBase.tsx +++ b/packages/ra-core/src/controller/list/ListBase.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import { ReactNode } from 'react'; import { useListController, ListControllerProps } from './useListController'; -import { ResourceContextProvider } from '../../core'; +import { OptionalResourceContextProvider } from '../../core'; import { RaRecord } from '../../types'; import { ListContextProvider } from './ListContextProvider'; +import { useIsAuthPending } from '../../auth'; /** * Call useListController and put the value in a ListContext @@ -41,11 +42,31 @@ import { ListContextProvider } from './ListContextProvider'; */ export const ListBase = ({ children, + loading = null, ...props -}: ListControllerProps & { children: ReactNode }) => ( - - (props)}> - {children} - - -); +}: ListBaseProps) => { + const controllerProps = useListController(props); + const isAuthPending = useIsAuthPending({ + resource: controllerProps.resource, + action: 'list', + }); + + if (isAuthPending && !props.disableAuthentication) { + return loading; + } + + return ( + // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided + + + {children} + + + ); +}; + +export interface ListBaseProps + extends ListControllerProps { + children: ReactNode; + loading?: ReactNode; +} diff --git a/packages/ra-core/src/controller/show/ShowBase.spec.tsx b/packages/ra-core/src/controller/show/ShowBase.spec.tsx new file mode 100644 index 00000000000..ce6b10ad916 --- /dev/null +++ b/packages/ra-core/src/controller/show/ShowBase.spec.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import expect from 'expect'; +import { render, screen, waitFor } from '@testing-library/react'; + +import { testDataProvider } from '../../dataProvider'; +import { + AccessControl, + NoAuthProvider, + WithAuthProviderNoAccessControl, +} from './ShowBase.stories'; + +describe('ShowBase', () => { + it('should load data immediately if authProvider is not provided', async () => { + const dataProvider = testDataProvider({ + // @ts-ignore + getOne: jest.fn(() => + Promise.resolve({ data: { id: 12, test: 'Hello' } }) + ), + }); + render(); + expect(dataProvider.getOne).toHaveBeenCalled(); + await screen.findByText('Hello'); + }); + it('should wait for the authentication resolution before loading data', async () => { + let resolveAuth: () => void; + const authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkError: () => Promise.resolve(), + checkAuth: () => + new Promise(resolve => { + resolveAuth = resolve; + }), + }; + const dataProvider = testDataProvider({ + // @ts-ignore + getOne: jest.fn(() => + Promise.resolve({ data: { id: 12, test: 'Hello' } }) + ), + }); + render( + + ); + expect(dataProvider.getOne).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + resolveAuth!(); + await screen.findByText('Hello'); + }); + it('should wait for both the authentication and authorization resolution before loading data', async () => { + let resolveAuth: () => void; + let resolveCanAccess: (value: boolean) => void; + const authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkError: () => Promise.resolve(), + checkAuth: () => + new Promise(resolve => { + resolveAuth = resolve; + }), + canAccess: jest.fn( + () => + new Promise(resolve => { + resolveCanAccess = resolve; + }) + ), + }; + const dataProvider = testDataProvider({ + // @ts-ignore + getOne: jest.fn(() => + Promise.resolve({ data: { id: 12, test: 'Hello' } }) + ), + }); + render( + + ); + expect(dataProvider.getOne).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + resolveAuth!(); + expect(dataProvider.getOne).not.toHaveBeenCalled(); + await screen.findByText('Authentication loading...'); + await waitFor(() => { + expect(authProvider.canAccess).toHaveBeenCalled(); + }); + resolveCanAccess!(true); + await screen.findByText('Hello'); + }); +}); diff --git a/packages/ra-core/src/controller/show/ShowBase.stories.tsx b/packages/ra-core/src/controller/show/ShowBase.stories.tsx new file mode 100644 index 00000000000..4162884b357 --- /dev/null +++ b/packages/ra-core/src/controller/show/ShowBase.stories.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { + AuthProvider, + CoreAdminContext, + ShowBase, + ShowBaseProps, + DataProvider, + testDataProvider, + useRecordContext, +} from '../..'; + +export default { + title: 'ra-core/controller/ShowBase', +}; + +export const NoAuthProvider = ({ + dataProvider = defaultDataProvider, + ...props +}: { + dataProvider?: DataProvider; +} & Partial) => ( + + + + + +); + +export const WithAuthProviderNoAccessControl = ({ + authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkError: () => Promise.resolve(), + checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), + }, + dataProvider = defaultDataProvider, +}: { + authProvider?: AuthProvider; + dataProvider?: DataProvider; +}) => ( + + Authentication loading...} + > + + + +); + +export const AccessControl = ({ + authProvider = { + login: () => Promise.resolve(), + logout: () => Promise.resolve(), + checkError: () => Promise.resolve(), + checkAuth: () => new Promise(resolve => setTimeout(resolve, 300)), + canAccess: () => new Promise(resolve => setTimeout(resolve, 300, true)), + }, + dataProvider = defaultDataProvider, +}: { + authProvider?: AuthProvider; + dataProvider?: DataProvider; +}) => ( + + Authentication loading...} + > + + + +); + +const defaultDataProvider = testDataProvider({ + // @ts-ignore + getOne: () => Promise.resolve({ data: { id: 12, test: 'Hello' } }), +}); + +const defaultProps = { + id: 12, + resource: 'posts', +}; + +const Child = () => { + const record = useRecordContext(); + + return

{record?.test}

; +}; diff --git a/packages/ra-core/src/controller/show/ShowBase.tsx b/packages/ra-core/src/controller/show/ShowBase.tsx index 75d9d5116ae..7b221c1715a 100644 --- a/packages/ra-core/src/controller/show/ShowBase.tsx +++ b/packages/ra-core/src/controller/show/ShowBase.tsx @@ -3,7 +3,8 @@ import * as React from 'react'; import { RaRecord } from '../../types'; import { useShowController, ShowControllerProps } from './useShowController'; import { ShowContextProvider } from './ShowContextProvider'; -import { ResourceContextProvider } from '../../core'; +import { OptionalResourceContextProvider } from '../../core'; +import { useIsAuthPending } from '../../auth'; /** * Call useShowController and put the value in a ShowContext @@ -36,20 +37,32 @@ import { ResourceContextProvider } from '../../core'; */ export const ShowBase = ({ children, + loading = null, ...props -}: { children: React.ReactNode } & ShowControllerProps) => { +}: ShowBaseProps) => { const controllerProps = useShowController(props); - const body = ( - - {children} - - ); - return props.resource ? ( - // support resource override via props - - {body} - - ) : ( - body + + const isAuthPending = useIsAuthPending({ + resource: controllerProps.resource, + action: 'show', + }); + + if (isAuthPending && !props.disableAuthentication) { + return loading; + } + + return ( + // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided + + + {children} + + ); }; + +export interface ShowBaseProps + extends ShowControllerProps { + children: React.ReactNode; + loading?: React.ReactNode; +} diff --git a/packages/ra-core/src/core/CoreAdminRoutes.tsx b/packages/ra-core/src/core/CoreAdminRoutes.tsx index b23c2491a6a..33f2c6658f1 100644 --- a/packages/ra-core/src/core/CoreAdminRoutes.tsx +++ b/packages/ra-core/src/core/CoreAdminRoutes.tsx @@ -117,6 +117,7 @@ export const CoreAdminRoutes = (props: CoreAdminRoutesProps) => { ) : ( @@ -91,9 +94,7 @@ export interface CreateProps< RecordType extends Omit = any, MutationOptionsError = Error, ResultRecordType extends RaRecord = RecordType & { id: Identifier }, -> extends CreateControllerProps< - RecordType, - MutationOptionsError, - ResultRecordType - >, - CreateViewProps {} +> extends CreateBaseProps, + Omit {} + +const defaultLoading = ; diff --git a/packages/ra-ui-materialui/src/detail/Edit.tsx b/packages/ra-ui-materialui/src/detail/Edit.tsx index b01e5030fa9..4f78cc70fb4 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.tsx @@ -3,9 +3,10 @@ import { EditBase, useCheckMinimumRequiredProps, RaRecord, - EditControllerProps, + EditBaseProps, } from 'ra-core'; import { EditView, EditViewProps } from './EditView'; +import { Loading } from '../layout'; /** * Page component for the Edit view @@ -65,6 +66,7 @@ export const Edit = ( redirect, transform, disableAuthentication, + loading = defaultLoading, ...rest } = props; return ( @@ -77,6 +79,7 @@ export const Edit = ( redirect={redirect} transform={transform} disableAuthentication={disableAuthentication} + loading={loading} > @@ -84,5 +87,7 @@ export const Edit = ( }; export interface EditProps - extends EditControllerProps, - EditViewProps {} + extends EditBaseProps, + Omit {} + +const defaultLoading = ; diff --git a/packages/ra-ui-materialui/src/detail/Show.tsx b/packages/ra-ui-materialui/src/detail/Show.tsx index 51cf21312cc..58df5bb7a05 100644 --- a/packages/ra-ui-materialui/src/detail/Show.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { ReactElement } from 'react'; -import { ShowBase, RaRecord, ShowControllerProps } from 'ra-core'; +import { ShowBase, RaRecord, ShowBaseProps } from 'ra-core'; import { ShowView, ShowViewProps } from './ShowView'; +import { Loading } from '../layout'; /** * Page component for the Show view @@ -60,6 +61,7 @@ export const Show = ({ resource, queryOptions, disableAuthentication, + loading = defaultLoading, ...rest }: ShowProps): ReactElement => ( @@ -67,11 +69,14 @@ export const Show = ({ disableAuthentication={disableAuthentication} queryOptions={queryOptions} resource={resource} + loading={loading} > ); export interface ShowProps - extends ShowControllerProps, - ShowViewProps {} + extends ShowBaseProps, + Omit {} + +const defaultLoading = ; diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.tsx index dc4ac29f70c..97395664ffc 100644 --- a/packages/ra-ui-materialui/src/list/InfiniteList.tsx +++ b/packages/ra-ui-materialui/src/list/InfiniteList.tsx @@ -1,13 +1,10 @@ import * as React from 'react'; import { ReactElement } from 'react'; -import { - InfiniteListBase, - InfiniteListControllerProps, - RaRecord, -} from 'ra-core'; +import { InfiniteListBase, InfiniteListBaseProps, RaRecord } from 'ra-core'; import { InfinitePagination } from './pagination'; import { ListView, ListViewProps } from './ListView'; +import { Loading } from '../layout'; /** * Infinite List page component @@ -69,6 +66,7 @@ export const InfiniteList = ({ exporter, filter = defaultFilter, filterDefaultValues, + loading = defaultLoading, pagination = defaultPagination, perPage = 10, queryOptions, @@ -84,6 +82,7 @@ export const InfiniteList = ({ exporter={exporter} filter={filter} filterDefaultValues={filterDefaultValues} + loading={loading} perPage={perPage} queryOptions={queryOptions} resource={resource} @@ -96,7 +95,8 @@ export const InfiniteList = ({ const defaultPagination = ; const defaultFilter = {}; +const defaultLoading = ; export interface InfiniteListProps - extends InfiniteListControllerProps, + extends InfiniteListBaseProps, ListViewProps {} diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index 0a32c3ee9bf..e562bf7ad50 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import { ReactElement } from 'react'; -import { ListBase, ListControllerProps, RaRecord } from 'ra-core'; +import { ListBase, ListBaseProps, RaRecord } from 'ra-core'; import { ListView, ListViewProps } from './ListView'; +import { Loading } from '../layout'; /** * List page component @@ -61,6 +62,7 @@ export const List = ({ exporter, filter = defaultFilter, filterDefaultValues, + loading = defaultLoading, perPage = 10, queryOptions, resource, @@ -75,6 +77,7 @@ export const List = ({ exporter={exporter} filter={filter} filterDefaultValues={filterDefaultValues} + loading={loading} perPage={perPage} queryOptions={queryOptions} resource={resource} @@ -86,7 +89,8 @@ export const List = ({ ); export interface ListProps - extends ListControllerProps, + extends ListBaseProps, ListViewProps {} const defaultFilter = {}; +const defaultLoading = ;