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

Add ability to refresh the user identity in useGetIdentity #8372

Merged
merged 8 commits into from
Nov 14, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
63 changes: 61 additions & 2 deletions docs/useGetIdentity.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,27 @@ title: "useGetIdentity"

# `useGetIdentity`

You may want to use the current user name, avatar, or id in your code. for that purpose, call the `useGetIdentity()` hook, which calls `authProvider.getIdentity()` on mount. It returns an object containing the loading state, the error state, and the identity.
React-admin calls `authProvider.getIdentity()` to retrieve and display the current logged in user name and avatar. The logic for calling this method is packaged into a custom hook, `useGetIdentity`, which you can use in your own code.
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

![identity](./img/identity.png)

## Syntax

`useGetIdentity()` calls `authProvider.getIdentity()` on mount. It returns an object containing the loading state, the error state, and the identity.

```jsx
const { data, isLoading, error } = useGetIdentity();
```

Once loaded, the `data` object contains the following properties:

```jsx
const { id, fullName, avatar } = identity;
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
```

`useGetIdentity` uses [react-query's `useQuery` hook](https://react-query-v3.tanstack.com/reference/useQuery) to call the `authProvider`.

## Usage

Here is an example Edit component, which falls back to a Show component if the record is locked for edition by another user:

Expand All @@ -14,7 +34,7 @@ import { useGetIdentity, useGetOne } from 'react-admin';

const PostDetail = ({ id }) => {
const { data: post, isLoading: postLoading } = useGetOne('posts', { id });
const { identity, isLoading: identityLoading } = useGetIdentity();
const { data: identity, isLoading: identityLoading } = useGetIdentity();
if (postLoading || identityLoading) return <>Loading...</>;
if (!post.lockedBy || post.lockedBy === identity.id) {
// post isn't locked, or is locked by me
Expand All @@ -25,3 +45,42 @@ const PostDetail = ({ id }) => {
}
}
```

## Refreshing The Identity

If your application contains a form letting the current user update their name and/or avatar, you may want to refresh the identity after the form is submitted. As `useGetIdentity` uses [react-query's `useQuery` hook](https://react-query-v3.tanstack.com/reference/useQuery) to call the `authProvider`, you can take advantage of the `refetch` function to do so:

```jsx
const IdentityForm = () => {
const { isLoading, error, data, refetch } = useGetIdentity();
const [newIdentity, setNewIdentity] = useState('');

if (isLoading) return <>Loading</>;
if (error) return <>Error</>;

const handleChange = event => {
setNewIdentity(event.target.value);
};

const handleSubmit = (e) => {
e.preventDefault();
if (!newIdentity) return;
fetch('/update_identity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: newIdentity })
}).then(() => {
// call authProvider.getIdentity() again and notify the listeners of the result,
// including the UserMenu in the AppBar
refetch();
});
};

return (
<form onSubmit={handleSubmit}>
<input defaultValue={data.fullName} onChange={handleChange} />
<input type="submit" value="Save" />
</form>
);
};
```
3 changes: 1 addition & 2 deletions packages/ra-core/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import usePermissionsOptimized from './usePermissionsOptimized';
import WithPermissions, { WithPermissionsProps } from './WithPermissions';
import useLogin from './useLogin';
import useLogout from './useLogout';
import useGetIdentity from './useGetIdentity';
import useGetPermissions from './useGetPermissions';
import useLogoutIfAccessDenied from './useLogoutIfAccessDenied';
import convertLegacyAuthProvider from './convertLegacyAuthProvider';
Expand All @@ -15,6 +14,7 @@ export * from './Authenticated';
export * from './types';
export * from './useAuthenticated';
export * from './useCheckAuth';
export * from './useGetIdentity';

export {
AuthContext,
Expand All @@ -23,7 +23,6 @@ export {
// low-level hooks for calling a particular verb on the authProvider
useLogin,
useLogout,
useGetIdentity,
useGetPermissions,
// hooks with state management
usePermissions,
Expand Down
25 changes: 25 additions & 0 deletions packages/ra-core/src/auth/useGetIdentity.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

import { Basic, ErrorCase, ResetIdentity } from './useGetIdentity.stories';

describe('useGetIdentity', () => {
it('should return the identity', async () => {
render(<Basic />);
await screen.findByText('John Doe');
});
it('should return the authProvider error', async () => {
jest.spyOn(console, 'error').mockImplementationOnce(() => {});
render(<ErrorCase />);
await screen.findByText('Error');
});
it('should allow to invalidate the query cache', async () => {
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
render(<ResetIdentity />);
expect(await screen.findByText('John Doe')).not.toBeNull();
const input = screen.getByDisplayValue('John Doe');
fireEvent.change(input, { target: { value: 'Jane Doe' } });
fireEvent.click(screen.getByText('Save'));
await screen.findByText('Jane Doe');
expect(screen.queryByText('John Doe')).toBeNull();
});
});
97 changes: 97 additions & 0 deletions packages/ra-core/src/auth/useGetIdentity.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import * as React from 'react';
import { QueryClientProvider, QueryClient, useQueryClient } from 'react-query';
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
import { useGetIdentity } from './useGetIdentity';
import AuthContext from './AuthContext';

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

const authProvider = {
login: () => Promise.resolve(),
logout: () => Promise.resolve(),
checkAuth: () => Promise.resolve(),
checkError: () => Promise.resolve(),
getPermissions: () => Promise.resolve(),
getIdentity: () => Promise.resolve({ id: 1, fullName: 'John Doe' }),
};

const Identity = () => {
const { data, error, isLoading } = useGetIdentity();
return isLoading ? <>Loading</> : error ? <>Error</> : <>{data.fullName}</>;
};

export const Basic = () => (
<QueryClientProvider client={new QueryClient()}>
<AuthContext.Provider value={authProvider}>
<Identity />
</AuthContext.Provider>
</QueryClientProvider>
);

export const ErrorCase = () => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
}
>
<AuthContext.Provider
value={{
...authProvider,
getIdentity: () => Promise.reject(new Error('Error')),
}}
>
<Identity />
</AuthContext.Provider>
</QueryClientProvider>
);

export const ResetIdentity = () => {
let fullName = 'John Doe';

const IdentityForm = () => {
const { isLoading, error, data, refetch } = useGetIdentity();
const [newIdentity, setNewIdentity] = React.useState('');

if (isLoading) return <>Loading</>;
if (error) return <>Error</>;

const handleChange = event => {
setNewIdentity(event.target.value);
};

const handleSubmit = e => {
e.preventDefault();
if (!newIdentity) return;
fullName = newIdentity;
refetch();
};

return (
<form onSubmit={handleSubmit}>
<input defaultValue={data.fullName} onChange={handleChange} />
<input type="submit" value="Save" />
</form>
);
};

return (
<QueryClientProvider client={new QueryClient()}>
<AuthContext.Provider
value={{
...authProvider,
getIdentity: () => Promise.resolve({ id: 1, fullName }),
}}
>
<Identity />
<IdentityForm />
</AuthContext.Provider>
</QueryClientProvider>
);
};
102 changes: 62 additions & 40 deletions packages/ra-core/src/auth/useGetIdentity.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
import { useEffect } from 'react';
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, QueryObserverResult } from 'react-query';

import useAuthProvider from './useAuthProvider';
import { UserIdentity } from '../types';
import { useSafeSetState } from '../util/hooks';

const defaultIdentity = {
id: '',
fullName: null,
slax57 marked this conversation as resolved.
Show resolved Hide resolved
};
const defaultQueryParams = {
staleTime: 5 * 60 * 1000,
};

/**
* Return the current user identity by calling authProvider.getIdentity() on mount
*
* The return value updates according to the call state:
*
* - mount: { isLoading: true }
* - success: { identity: Identity, isLoading: false }
* - success: { data: Identity, refetch: () => {}, isLoading: false }
* - error: { error: Error, isLoading: false }
*
* The implementation is left to the authProvider.
*
* @returns The current user identity. Destructure as { identity, error, isLoading }.
* @returns The current user identity. Destructure as { isLoading, data, error, refetch }.
*
* @example
*
* import { useGetIdentity, useGetOne } from 'react-admin';
*
* const PostDetail = ({ id }) => {
* const { data: post, isLoading: postLoading } = useGetOne('posts', { id });
* const { identity, isLoading: identityLoading } = useGetIdentity();
* const { data: identity, isLoading: identityLoading } = useGetIdentity();
slax57 marked this conversation as resolved.
Show resolved Hide resolved
* if (postLoading || identityLoading) return <>Loading...</>;
* if (!post.lockedBy || post.lockedBy === identity.id) {
* // post isn't locked, or is locked by me
Expand All @@ -38,42 +41,61 @@ const defaultIdentity = {
* }
* }
*/
const useGetIdentity = () => {
const [state, setState] = useSafeSetState<State>({
isLoading: true,
});
export const useGetIdentity = (
queryParams: UseQueryOptions<UserIdentity, Error> = defaultQueryParams
): UseGetIdentityResult => {
const authProvider = useAuthProvider();
useEffect(() => {
if (authProvider && typeof authProvider.getIdentity === 'function') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing typeof authProvider.getIdentity === 'function' check causes a tiny BC break.

We found there are console errors after upgrading pass 4.6 because we have never implemented the getIdentity method since it was introduced in #5180

In the previous pull request it says

The change is backward compatible (if the authProvider.getIdentity() method isn't implemented, the admin shows an anonymous user avatar with no name, as before).

In v4 upgrade guide it did not mention this method is now required to be implemented. I believe we should now add a note about that breaking change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be very easy for us to catch the issue and implement the method.

Just for other users they should be aware when they upgrade.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing that out!
The getIdentity function should remain optional, even in v4, so I believe we should reintroduce this check to avoid errors. I'll open a new PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here it is: #8509

const callAuthProvider = async () => {
try {
const identity = await authProvider.getIdentity();
setState({
isLoading: false,
identity: identity || defaultIdentity,
});
} catch (error) {
setState({
isLoading: false,
error,
});
}
};
callAuthProvider();
} else {
setState({
isLoading: false,
identity: defaultIdentity,
});
}
}, [authProvider, setState]);
return state;

const result = useQuery(
['auth', 'getIdentity'],
authProvider
? () => authProvider.getIdentity()
: async () => defaultIdentity,
queryParams
);

// @FIXME: return useQuery's result directly by removing identity prop (BC break - to be done in v5)
return useMemo(
() =>
result.isLoading
? { isLoading: true }
: result.error
? { error: result.error, isLoading: false }
: {
data: result.data,
identity: result.data,
refetch: result.refetch,
isLoading: false,
},
slax57 marked this conversation as resolved.
Show resolved Hide resolved

[result]
);
};

interface State {
isLoading: boolean;
identity?: UserIdentity;
error?: any;
}
export type UseGetIdentityResult =
| {
isLoading: true;
data?: undefined;
identity?: undefined;
error?: undefined;
refetch?: undefined;
}
| {
isLoading: false;
data?: undefined;
identity?: undefined;
error: Error;
refetch?: undefined;
}
| {
isLoading: false;
data: UserIdentity;
/**
* @deprecated Use data instead
*/
identity: UserIdentity;
error?: undefined;
refetch: () => Promise<QueryObserverResult<UserIdentity, Error>>;
};

export default useGetIdentity;
9 changes: 5 additions & 4 deletions packages/ra-core/src/auth/usePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ const usePermissions = <Permissions = any, Error = any>(
queryParams
);

return useMemo(() => {
return {
return useMemo(
() => ({
permissions: result.data,
isLoading: result.isLoading,
error: result.error,
};
}, [result]);
}),
slax57 marked this conversation as resolved.
Show resolved Hide resolved
[result]
);
};

export default usePermissions;