Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce <NavigateToFirstResource> #10255

Merged
merged 7 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/Admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,38 @@ const authProvider = {
}
```

**Tip**: If your authProvider implements [the `canAccess` method](./AuthProviderWriting.md#canaccess) and you don't provide a dashboard, React-Admin will use the first resource for which users have access to the list page as the home page for your admin. Make sure you order them to suit your needs.

**Tip**: The detection of the first resource implies checking users are authenticated. Should your first resource be accessible without authentication or access right checks, you must provide a dashboard that redirects to it:
djhi marked this conversation as resolved.
Show resolved Hide resolved

```tsx
// in src/Dashboard.js
import * as React from "react";
import { Navigate } from 'react-router';
import { Title } from 'react-admin';

export const Dashboard = () => (
<Navigate to="/unprotected" />
);
```

```tsx
// in src/App.js
import * as React from "react";
import { Admin, Resource } from 'react-admin';
import simpleRestProvider from 'ra-data-simple-rest';
import { authProvider } from './authProvider';

import { Dashboard } from './Dashboard';

const App = () => (
<Admin dashboard={Dashboard} authProvider={authProvider} dataProvider={simpleRestProvider('http://path.to.my.api')}>
<Resource name="unprotected" list={<UnprotectedList disableAuthentication />} />
<Resource name="protected" {/* ... */ } />
</Admin>
);
```

## `darkTheme`

React-admin provides a [built-in dark theme](./AppTheme.md#default). The app will use the `darkTheme` by default for users who prefer the dark mode at the OS level, and users will be able to switch from light to dark mode using [the `<ToggleThemeButton>` component](./ToggleThemeButton.md).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,8 @@ describe('useInfiniteListController', () => {
);
await screen.findByText('A post - 0 votes');
expect(dataProvider.getList).toHaveBeenCalled();
expect(authProvider.checkAuth).not.toHaveBeenCalled();
// Only called once by NavigationToFirstResource
expect(authProvider.checkAuth).toHaveBeenCalledTimes(1);
});
});
});
26 changes: 7 additions & 19 deletions packages/ra-core/src/core/CoreAdminRoutes.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from 'react';
import { useState, useEffect, Children, ComponentType } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Route, Routes } from 'react-router-dom';
import { WithPermissions, useCheckAuth, LogoutOnMount } from '../auth';
import { useScrollToTop, useCreatePath } from '../routing';
import { useScrollToTop } from '../routing';
import {
AdminChildren,
CatchAllComponent,
Expand All @@ -12,11 +12,10 @@ import {
} from '../types';
import { useConfigureAdminRouterFromChildren } from './useConfigureAdminRouterFromChildren';
import { HasDashboardContextProvider } from './HasDashboardContext';
import { useFirstResourceWithListAccess } from './useFirstResourceWithListAccess';
import { NavigateToFirstResource } from './NavigateToFirstResource';

export const CoreAdminRoutes = (props: CoreAdminRoutesProps) => {
useScrollToTop();
const createPath = useCreatePath();

const {
customRoutesWithLayout,
Expand Down Expand Up @@ -55,11 +54,6 @@ export const CoreAdminRoutes = (props: CoreAdminRoutesProps) => {
}
}, [checkAuth, requireAuth]);

const {
isPending: isPendingFirstResourceWithListAccess,
resource: firstResourceWithListAccess,
} = useFirstResourceWithListAccess(resources);

if (status === 'empty') {
if (!Ready) {
throw new Error(
Expand Down Expand Up @@ -124,17 +118,11 @@ export const CoreAdminRoutes = (props: CoreAdminRoutesProps) => {
authParams={defaultAuthParams}
component={dashboard}
/>
) : firstResourceWithListAccess ? (
<Navigate
to={createPath({
resource:
firstResourceWithListAccess,
type: 'list',
})}
) : (
<NavigateToFirstResource
loading={LoadingPage}
/>
) : isPendingFirstResourceWithListAccess ? (
<LoadingPage />
) : null
)
}
/>
<Route
Expand Down
21 changes: 21 additions & 0 deletions packages/ra-core/src/core/NavigateToFirstResource.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import {
AccessControl,
NoAuthProvider,
} from './NavigateToFirstResource.stories';

describe('<NavigateToFirstResource>', () => {
it('should render the first resource with a list when there is no AuthProvider', async () => {
render(<NoAuthProvider />);
await screen.findByText('Posts');
});

it('should render the first resource with a list users have access to', async () => {
render(<AccessControl />);
await screen.findByText('Posts');
fireEvent.click(screen.getByLabelText('posts.list access'));
fireEvent.click(screen.getByText('Go home'));
await screen.findByText('Users');
});
});
138 changes: 138 additions & 0 deletions packages/ra-core/src/core/NavigateToFirstResource.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import * as React from 'react';
import { TestMemoryRouter } from '../routing';
import { CoreAdmin } from './CoreAdmin';
import { Resource } from './Resource';
import { Browser } from '../storybook/FakeBrowser';
import { QueryClient } from '@tanstack/react-query';
import { AuthProvider } from '../types';
import { Link } from 'react-router-dom';

export default {
title: 'ra-core/core/NavigateToFirstResource',
};

export const NoAuthProvider = () => (
<TestMemoryRouter>
<CoreAdmin>
<Resource name="settings" edit={() => <div>Settings</div>} />
<Resource name="posts" list={() => <div>Posts</div>} />
<Resource name="users" list={() => <div>Users</div>} />
</CoreAdmin>
</TestMemoryRouter>
);

export const AccessControl = () => (
<TestMemoryRouter>
<AccessControlAdmin queryClient={new QueryClient()} />
</TestMemoryRouter>
);

const AccessControlAdmin = ({ queryClient }: { queryClient: QueryClient }) => {
const [authorizedResources, setAuthorizedResources] = React.useState({
'posts.list': true,
'users.list': true,
});

const authProvider: AuthProvider = {
login: () => Promise.reject(new Error('Not implemented')),
logout: () => Promise.reject(new Error('Not implemented')),
checkAuth: () => Promise.resolve(),
checkError: () => Promise.reject(new Error('Not implemented')),
getPermissions: () => Promise.resolve(undefined),
canAccess: ({ action, resource }) =>
new Promise(resolve => {
setTimeout(() => {
resolve(authorizedResources[`${resource}.${action}`]);
}, 300);
}),
};
return (
<CoreAdmin
queryClient={queryClient}
authProvider={authProvider}
layout={({ children }) => (
<AccessControlUI
authorizedResources={authorizedResources}
setAuthorizedResources={setAuthorizedResources}
queryClient={queryClient}
>
{children}
</AccessControlUI>
)}
>
<Resource name="settings" edit={() => <div>Settings</div>} />
<Resource
name="posts"
list={() => (
<div>
<div>Posts</div>
<Link to="/">Go home</Link>
</div>
)}
/>
<Resource
name="users"
list={() => (
<div>
<div>Users</div>
<Link to="/">Go home</Link>
</div>
)}
/>
</CoreAdmin>
);
};
const AccessControlUI = ({
children,
setAuthorizedResources,
authorizedResources,
queryClient,
}: {
children: React.ReactNode;
setAuthorizedResources: Function;
authorizedResources: {
'posts.list': boolean;
'users.list': boolean;
};
queryClient: QueryClient;
}) => {
return (
<div>
<div>
<label>
<input
type="checkbox"
checked={authorizedResources['posts.list']}
onChange={() => {
setAuthorizedResources(state => ({
...state,
'posts.list':
!authorizedResources['posts.list'],
}));

queryClient.clear();
}}
/>
posts.list access
</label>
<label>
<input
type="checkbox"
checked={authorizedResources['users.list']}
onChange={() => {
setAuthorizedResources(state => ({
...state,
'users.list':
!authorizedResources['users.list'],
}));

queryClient.clear();
}}
/>
users.list access
</label>
</div>
<Browser>{children}</Browser>
</div>
);
};
35 changes: 35 additions & 0 deletions packages/ra-core/src/core/NavigateToFirstResource.tsx
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as React from 'react';
import { Navigate } from 'react-router';
import { useFirstResourceWithListAccess } from './useFirstResourceWithListAccess';
import { useCreatePath } from '../routing';

/**
* This component will inspect the registered resources and navigate to the first one for which users have access to the list page.
* @param props
* @param props.loading The component to display while the component is loading.
*/
export const NavigateToFirstResource = ({
loading: LoadingPage,
}: NavigateToFirstResourceProps) => {
const { resource, isPending } = useFirstResourceWithListAccess();
const createPath = useCreatePath();

if (isPending) {
return <LoadingPage />;
}

if (resource) {
return (
<Navigate
to={createPath({
resource,
type: 'list',
})}
/>
);
}
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
};

export type NavigateToFirstResourceProps = {
loading: React.ComponentType;
};
15 changes: 11 additions & 4 deletions packages/ra-core/src/core/useFirstResourceWithListAccess.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { ReactElement } from 'react';
import { useCanAccessResources } from '../auth/useCanAccessResources';
import { useAuthenticated } from '../auth';
import { useResourceDefinitions } from './useResourceDefinitions';

/**
* A hook that returns the first resource users have list access to.
* A hook that returns the first resource for which users have access to the list page.
* It calls the `authProvider.canAccess` if available to check the permissions.
*/
export const useFirstResourceWithListAccess = (resources: ReactElement[]) => {
const resourcesNames = resources.map(resource => resource.props.name);
export const useFirstResourceWithListAccess = () => {
const { isPending: isPendingAuthenticated } = useAuthenticated();
const resources = useResourceDefinitions();
const resourcesNames = Object.keys(resources).filter(
resource => resources[resource].hasList
);

const { canAccess, isPending } = useCanAccessResources({
action: 'list',
resources: resourcesNames,
enabled: !isPendingAuthenticated,
});

const firstResourceWithListAccess = resourcesNames.find(
Expand Down
Loading