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 ;
- };
- const { getByLabelText } = render(
-
-
-
-
-
+ render(
+
);
- getByLabelText('save').click();
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith(
@@ -91,6 +60,7 @@ describe('CreateBase', () => {
it('should allow to override the onSuccess function at call time', async () => {
const dataProvider = testDataProvider({
+ // @ts-ignore
create: jest.fn((_, { data }) =>
Promise.resolve({ data: { id: 1, ...data } })
),
@@ -98,27 +68,15 @@ describe('CreateBase', () => {
const onSuccess = jest.fn();
const onSuccessOverride = jest.fn();
- const Child = () => {
- const saveContext = useSaveContext();
-
- const handleClick = () => {
- saveContext.save(
- { test: 'test' },
- { onSuccess: onSuccessOverride }
- );
- };
-
- return ;
- };
- const { getByLabelText } = render(
-
-
-
-
-
+ const { getByText } = render(
+
);
- getByLabelText('save').click();
+ getByText('save').click();
await waitFor(() => {
expect(onSuccessOverride).toHaveBeenCalledWith(
@@ -136,28 +94,19 @@ describe('CreateBase', () => {
it('should allow to override the onError function', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
const dataProvider = testDataProvider({
+ // @ts-ignore
create: jest.fn(() => Promise.reject({ message: 'test' })),
});
const onError = jest.fn();
- const Child = () => {
- const saveContext = useSaveContext();
-
- const handleClick = () => {
- saveContext.save({ test: 'test' });
- };
-
- return ;
- };
render(
-
-
-
-
-
+
);
- screen.getByLabelText('save').click();
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(
@@ -170,32 +119,21 @@ describe('CreateBase', () => {
it('should allow to override the onError function at call time', async () => {
const dataProvider = testDataProvider({
+ // @ts-ignore
create: jest.fn(() => Promise.reject({ message: 'test' })),
});
const onError = jest.fn();
const onErrorOverride = jest.fn();
- const Child = () => {
- const saveContext = useSaveContext();
-
- const handleClick = () => {
- saveContext.save(
- { test: 'test' },
- { onError: onErrorOverride }
- );
- };
-
- return ;
- };
render(
-
-
-
-
-
+
);
- screen.getByLabelText('save').click();
+ screen.getByText('save').click();
await waitFor(() => {
expect(onErrorOverride).toHaveBeenCalledWith(
@@ -209,6 +147,7 @@ describe('CreateBase', () => {
it('should allow to override the transform function', async () => {
const dataProvider = testDataProvider({
+ // @ts-ignore
create: jest.fn((_, { data }) =>
Promise.resolve({ data: { id: 1, ...data } })
),
@@ -217,24 +156,11 @@ describe('CreateBase', () => {
.fn()
.mockReturnValueOnce({ test: 'test transformed' });
- const Child = () => {
- const saveContext = useSaveContext();
-
- const handleClick = () => {
- saveContext.save({ test: 'test' });
- };
-
- return ;
- };
render(
-
-
-
-
-
+
);
- screen.getByLabelText('save').click();
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(transform).toHaveBeenCalledWith({ test: 'test' });
@@ -248,6 +174,7 @@ describe('CreateBase', () => {
it('should allow to override the transform function at call time', async () => {
const dataProvider = testDataProvider({
+ // @ts-ignore
create: jest.fn((_, { data }) =>
Promise.resolve({ data: { id: 1, ...data } })
),
@@ -257,27 +184,17 @@ describe('CreateBase', () => {
.fn()
.mockReturnValueOnce({ test: 'test transformed' });
- const Child = () => {
- const saveContext = useSaveContext();
-
- const handleClick = () => {
- saveContext.save(
- { test: 'test' },
- { transform: transformOverride }
- );
- };
-
- return ;
- };
render(
-
-
-
-
-
+
);
- screen.getByLabelText('save').click();
+ screen.getByText('save').click();
await waitFor(() => {
expect(transformOverride).toHaveBeenCalledWith({ test: 'test' });
@@ -289,4 +206,66 @@ describe('CreateBase', () => {
});
expect(transform).not.toHaveBeenCalled();
});
+
+ it('should show the view immediately if authProvider is not provided', () => {
+ const dataProvider = testDataProvider();
+ render();
+ screen.getByText('save');
+ });
+ it('should wait for the authentication resolution before showing the view', async () => {
+ let resolveAuth: () => void;
+ const authProvider = {
+ login: () => Promise.resolve(),
+ logout: () => Promise.resolve(),
+ checkError: () => Promise.resolve(),
+ checkAuth: () =>
+ new Promise(resolve => {
+ resolveAuth = resolve;
+ }),
+ };
+ const dataProvider = testDataProvider();
+ render(
+
+ );
+ await screen.findByText('Authentication loading...');
+ resolveAuth!();
+ await screen.findByText('save');
+ });
+ it('should wait for both the authentication and authorization resolution before showing the view', 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();
+ render(
+
+ );
+ await screen.findByText('Authentication loading...');
+ resolveAuth!();
+ await screen.findByText('Authentication loading...');
+ await waitFor(() => {
+ expect(authProvider.canAccess).toHaveBeenCalled();
+ });
+ resolveCanAccess!(true);
+ await screen.findByText('save');
+ });
});
diff --git a/packages/ra-core/src/controller/create/CreateBase.stories.tsx b/packages/ra-core/src/controller/create/CreateBase.stories.tsx
new file mode 100644
index 00000000000..60a1bd76b41
--- /dev/null
+++ b/packages/ra-core/src/controller/create/CreateBase.stories.tsx
@@ -0,0 +1,104 @@
+import * as React from 'react';
+import {
+ AuthProvider,
+ CoreAdminContext,
+ CreateBase,
+ CreateBaseProps,
+ DataProvider,
+ SaveHandlerCallbacks,
+ testDataProvider,
+ useSaveContext,
+} from '../..';
+
+export default {
+ title: 'ra-core/controller/CreateBase',
+};
+
+export const NoAuthProvider = ({
+ dataProvider = defaultDataProvider,
+ callTimeOptions,
+ ...props
+}: {
+ dataProvider?: DataProvider;
+ callTimeOptions?: SaveHandlerCallbacks;
+} & 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
+ create: (_, { data }) => Promise.resolve({ data: { id: 1, ...data } }),
+});
+
+const defaultProps = {
+ hasCreate: true,
+ hasEdit: true,
+ hasList: true,
+ hasShow: true,
+ id: 12,
+ resource: 'posts',
+};
+
+const Child = ({
+ callTimeOptions,
+}: {
+ callTimeOptions?: SaveHandlerCallbacks;
+}) => {
+ const saveContext = useSaveContext();
+
+ const handleClick = () => {
+ if (!saveContext || !saveContext.save) return;
+ saveContext.save({ test: 'test' }, callTimeOptions);
+ };
+
+ 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}
-
- >
- );
- };
render(
-
-
-
-
-
+
);
await waitFor(() => {
screen.getByText('previous');
});
- screen.getByLabelText('save').click();
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(dataProvider.update).toHaveBeenCalledWith('posts', {
- id: defaultProps.id,
+ id: 12,
data: { test: 'test' },
previousData: { id: 12, test: 'previous' },
});
@@ -68,8 +43,8 @@ describe('EditBase', () => {
it('should allow to override the onSuccess 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 } })
@@ -77,37 +52,18 @@ describe('EditBase', () => {
});
const onSuccess = jest.fn();
- const Child = () => {
- const saveContext = useSaveContext();
- const record = useRecordContext();
-
- const handleClick = () => {
- saveContext.save({ test: 'test' });
- };
-
- return (
- <>
- {record?.test}
-
- >
- );
- };
- const { getByLabelText } = render(
-
-
-
-
-
+ render(
+
);
await waitFor(() => {
screen.getByText('previous');
});
- getByLabelText('save').click();
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith(
@@ -127,8 +83,8 @@ describe('EditBase', () => {
it('should allow to override the onSuccess function at call time', 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 } })
@@ -137,40 +93,19 @@ describe('EditBase', () => {
const onSuccess = jest.fn();
const onSuccessOverride = jest.fn();
- const Child = () => {
- const saveContext = useSaveContext();
- const record = useRecordContext();
-
- const handleClick = () => {
- saveContext.save(
- { test: 'test' },
- { onSuccess: onSuccessOverride }
- );
- };
-
- return (
- <>
- {record?.test}
-
- >
- );
- };
- const { getByLabelText } = render(
-
-
-
-
-
+ render(
+
);
await waitFor(() => {
screen.getByText('previous');
});
- getByLabelText('save').click();
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(onSuccessOverride).toHaveBeenCalledWith(
@@ -200,37 +135,16 @@ describe('EditBase', () => {
});
const onError = jest.fn();
- const Child = () => {
- const saveContext = useSaveContext();
- const record = useRecordContext();
-
- const handleClick = () => {
- saveContext.save({ test: 'test' });
- };
-
- return (
- <>
- {record?.test}
-
- >
- );
- };
- const { getByLabelText } = render(
-
-
-
-
-
+ render(
+
);
- await waitFor(() => {
- screen.getByText('previous');
- });
- getByLabelText('save').click();
+ await screen.findByText('previous');
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(
@@ -256,40 +170,17 @@ describe('EditBase', () => {
const onError = jest.fn();
const onErrorOverride = jest.fn();
- const Child = () => {
- const saveContext = useSaveContext();
- const record = useRecordContext();
-
- const handleClick = () => {
- saveContext.save(
- { test: 'test' },
- { onError: onErrorOverride }
- );
- };
-
- return (
- <>
- {record?.test}
-
- >
- );
- };
- const { getByLabelText } = render(
-
-
-
-
-
+ render(
+
);
- await waitFor(() => {
- screen.getByText('previous');
- });
- getByLabelText('save').click();
+ await screen.findByText('previous');
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(onErrorOverride).toHaveBeenCalledWith(
@@ -307,8 +198,8 @@ describe('EditBase', () => {
it('should allow to override the transform 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 } })
@@ -319,37 +210,16 @@ describe('EditBase', () => {
test: 'test transformed',
}));
- const Child = () => {
- const saveContext = useSaveContext();
- const record = useRecordContext();
-
- const handleClick = () => {
- saveContext.save({ test: 'test' });
- };
-
- return (
- <>
- {record?.test}
-
- >
- );
- };
- const { getByLabelText } = render(
-
-
-
-
-
+ render(
+
);
- await waitFor(() => {
- screen.getByText('previous');
- });
- getByLabelText('save').click();
+ await screen.findByText('previous');
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(transform).toHaveBeenCalledWith(
@@ -359,7 +229,7 @@ describe('EditBase', () => {
});
await waitFor(() => {
expect(dataProvider.update).toHaveBeenCalledWith('posts', {
- id: defaultProps.id,
+ id: 12,
data: { test: 'test transformed' },
previousData: { id: 12, test: 'previous' },
});
@@ -368,8 +238,8 @@ describe('EditBase', () => {
it('should allow to override the transform function at call time', 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 } })
@@ -381,40 +251,19 @@ describe('EditBase', () => {
test: 'test transformed',
}));
- const Child = () => {
- const saveContext = useSaveContext();
- const record = useRecordContext();
-
- const handleClick = () => {
- saveContext.save(
- { test: 'test' },
- { transform: transformOverride }
- );
- };
-
- return (
- <>
- {record?.test}
-
- >
- );
- };
- const { getByLabelText } = render(
-
-
-
-
-
+ render(
+
);
- await waitFor(() => {
- screen.getByText('previous');
- });
- getByLabelText('save').click();
+ await screen.findByText('previous');
+ fireEvent.click(screen.getByText('save'));
await waitFor(() => {
expect(transformOverride).toHaveBeenCalledWith(
@@ -424,11 +273,92 @@ describe('EditBase', () => {
});
await waitFor(() => {
expect(dataProvider.update).toHaveBeenCalledWith('posts', {
- id: defaultProps.id,
+ id: 12,
data: { test: 'test transformed' },
previousData: { id: 12, test: 'previous' },
});
});
expect(transform).not.toHaveBeenCalled();
});
+
+ 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/edit/EditBase.stories.tsx b/packages/ra-core/src/controller/edit/EditBase.stories.tsx
new file mode 100644
index 00000000000..eb4de73c5a3
--- /dev/null
+++ b/packages/ra-core/src/controller/edit/EditBase.stories.tsx
@@ -0,0 +1,107 @@
+import * as React from 'react';
+import {
+ AuthProvider,
+ CoreAdminContext,
+ EditBase,
+ EditBaseProps,
+ DataProvider,
+ SaveHandlerCallbacks,
+ testDataProvider,
+ useSaveContext,
+ useRecordContext,
+} from '../..';
+
+export default {
+ title: 'ra-core/controller/EditBase',
+};
+
+export const NoAuthProvider = ({
+ dataProvider = defaultDataProvider,
+ callTimeOptions,
+ ...props
+}: {
+ dataProvider?: DataProvider;
+ callTimeOptions?: SaveHandlerCallbacks;
+} & 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 = ({
+ callTimeOptions,
+}: {
+ callTimeOptions?: SaveHandlerCallbacks;
+}) => {
+ const saveContext = useSaveContext();
+ const record = useRecordContext();
+
+ const handleClick = () => {
+ if (!saveContext || !saveContext.save) return;
+ saveContext.save({ test: 'test' }, callTimeOptions);
+ };
+
+ 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 = ;