Merge pull request #16816 from strapi/enhancement/use-admin-users

Enhancement: Create useAdminUsers data fetching hook
This commit is contained in:
Gustav Hansen 2023-05-25 12:25:34 +02:00 committed by GitHub
commit aa461fbbc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 442 additions and 211 deletions

View File

@ -0,0 +1,5 @@
{
"label": "Hooks",
"collapsible": true,
"collapsed": true
}

View File

@ -0,0 +1,67 @@
---
title: useAdminUsers
description: API reference for the useAdminUsers hook
tags:
- admin
- hooks
- users
---
An abstraction around `react-query`'s `useQuery` hook. It can be used to fetch one ore more admin users.
## Usage
The hooks can receive two optional parameters:
1. query params: an object containing the query params to be sent to the API. They are going to be
stringified by `qs`. All params are equal except `id`, which is used to fetch a single users, if
it is passed.
2. options: an object containing the options to be passed to `useQuery`.
It returns an object containing some of the react-query attributes.
## Typescript
```ts
import { UseQueryOptions } from 'react-query'
type User = object;
useAdminUsers(queryParams: object, reactQueryOptions: UseQueryOptions): {
users: User[];
pagination: {
page: number,
pageSize: number,
total: number,
} | null;
isLoading: boolean;
isError: boolean;
refetch: () => Promise<void>;
};
```
### Fetch all users
```jsx
import { useAdminUsers } from 'path/to/hooks';
const MyComponent = ({ onMoveItem }) => {
const { users, isLoading, refetch } = useAdminUsers();
return /* ... */;
};
```
### Fetch one user
```jsx
import { Box } from '@strapi/design-system';
import { useAdminUsers } from 'path/to/hooks';
const MyComponent = ({ onMoveItem }) => {
const { users: [user], isLoading, refetch } = useAdminUsers({ id: 1 });
return /* ... */;
};
```

View File

@ -0,0 +1,5 @@
export const useAdminUsers = jest.fn().mockReturnValue({
users: [],
isLoading: false,
isError: false,
});

View File

@ -0,0 +1 @@
export * from './useAdminUsers';

View File

@ -0,0 +1,137 @@
import * as React from 'react';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { renderHook } from '@testing-library/react-hooks';
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query';
import { useAdminUsers } from '../useAdminUsers';
const server = setupServer(
rest.get('*/users', (req, res, ctx) =>
res(
ctx.json({
data: {
results: [
{
id: 1,
},
],
pagination: {
page: 1,
},
},
})
)
),
rest.get('*/users/1', (req, res, ctx) =>
res(
ctx.json({
data: {
id: 1,
params: {
some: req.url.searchParams.get('some'),
},
},
})
)
)
);
const setup = (...args) =>
renderHook(() => useAdminUsers(...args), {
wrapper({ children }) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return (
<QueryClientProvider client={client}>
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
</QueryClientProvider>
);
},
});
describe('useAdminUsers', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
test('fetches users', async () => {
const { result, waitFor } = setup();
expect(result.current.isLoading).toBe(true);
expect(result.current.users).toStrictEqual([]);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.users).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
id: 1,
}),
])
);
expect(result.current.pagination).toStrictEqual(
expect.objectContaining({
page: 1,
})
);
});
test('fetches a single user', async () => {
const { result, waitFor } = setup({ id: 1 });
expect(result.current.isLoading).toBe(true);
expect(result.current.users).toStrictEqual([]);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.users).toStrictEqual([
expect.objectContaining({
id: 1,
}),
]);
});
test('forwards all query params except `id`', async () => {
const { result, waitFor } = setup({ id: 1, some: 'param' });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.users).toStrictEqual([
expect.objectContaining({
params: {
some: 'param',
},
}),
]);
});
test('extends the default react-query options', async () => {
const { result } = setup(
{ id: null },
{
enabled: false,
}
);
expect(result.current.isLoading).toBe(false);
});
});

View File

@ -0,0 +1,38 @@
import { useQuery } from 'react-query';
import { useFetchClient } from '@strapi/helper-plugin';
import { stringify } from 'qs';
export function useAdminUsers(params = {}, queryOptions = {}) {
const { id = '', ...queryParams } = params;
const queryString = stringify(queryParams, { encode: false });
const { get } = useFetchClient();
const { data, isError, isLoading, refetch } = useQuery(
['users', id, queryParams],
async () => {
const {
data: { data },
} = await get(`/admin/users/${id}${queryString ? `?${queryString}` : ''}`);
return data;
},
queryOptions
);
let users = [];
if (id && data) {
users = [data];
} else if (Array.isArray(data?.results)) {
users = data.results;
}
return {
users,
pagination: data?.pagination ?? null,
isLoading,
isError,
refetch,
};
}

View File

@ -17,7 +17,6 @@ import {
LoadingIndicatorPage,
Link,
} from '@strapi/helper-plugin';
import { useQuery } from 'react-query';
import { Formik } from 'formik';
import {
Box,
@ -32,11 +31,13 @@ import {
} from '@strapi/design-system';
import { ArrowLeft, Check } from '@strapi/icons';
import MagicLink from 'ee_else_ce/pages/SettingsPage/pages/Users/components/MagicLink';
import { formatAPIErrors, getFullName } from '../../../../../utils';
import { fetchUser, putUser } from './utils/api';
import { putUser } from './utils/api';
import layout from './utils/layout';
import { editValidation } from '../utils/validations/users';
import SelectRoles from '../components/SelectRoles';
import { useAdminUsers } from '../../../../../hooks/useAdminUsers';
const fieldsToPick = ['email', 'firstname', 'lastname', 'username', 'isActive', 'roles'];
@ -51,26 +52,35 @@ const EditPage = ({ canUpdate }) => {
const { lockApp, unlockApp } = useOverlayBlocker();
useFocusWhenNavigate();
const { status, data } = useQuery(['user', id], () => fetchUser(id), {
retry: false,
onError(err) {
const status = err.response.status;
const {
users: [user],
isLoading,
} = useAdminUsers(
{ id },
{
onError(error) {
const { status } = error.response;
// Redirect the use to the homepage if is not allowed to read
if (status === 403) {
toggleNotification({
type: 'info',
message: {
id: 'notification.permission.not-allowed-read',
defaultMessage: 'You are not allowed to see this document',
},
});
// Redirect the use to the homepage if is not allowed to read
if (status === 403) {
toggleNotification({
type: 'info',
message: {
id: 'notification.permission.not-allowed-read',
defaultMessage: 'You are not allowed to see this document',
},
});
push('/');
}
console.log(err.response.status);
},
});
push('/');
} else {
toggleNotification({
type: 'warning',
message: { id: 'notification.error', defaultMessage: 'An error occured' },
});
}
},
}
);
const handleSubmit = async (body, actions) => {
lockApp();
@ -113,19 +123,18 @@ const EditPage = ({ canUpdate }) => {
unlockApp();
};
const isLoading = status !== 'success';
const headerLabel = isLoading
? { id: 'app.containers.Users.EditPage.header.label-loading', defaultMessage: 'Edit user' }
: { id: 'app.containers.Users.EditPage.header.label', defaultMessage: 'Edit {name}' };
const initialData = Object.keys(pick(data, fieldsToPick)).reduce((acc, current) => {
const initialData = Object.keys(pick(user, fieldsToPick)).reduce((acc, current) => {
if (current === 'roles') {
acc[current] = (data?.roles || []).map(({ id }) => id);
acc[current] = (user?.roles || []).map(({ id }) => id);
return acc;
}
acc[current] = data?.[current];
acc[current] = user?.[current];
return acc;
}, {});
@ -138,6 +147,7 @@ const EditPage = ({ canUpdate }) => {
if (isLoading) {
return (
<Main aria-busy="true">
{/* TODO: translate */}
<SettingsPageTitle name="Users" />
<HeaderLayout
primaryAction={
@ -200,9 +210,9 @@ const EditPage = ({ canUpdate }) => {
}
/>
<ContentLayout>
{data?.registrationToken && (
{user?.registrationToken && (
<Box paddingBottom={6}>
<MagicLink registrationToken={data.registrationToken} />
<MagicLink registrationToken={user.registrationToken} />
</Box>
)}
<Flex direction="column" alignItems="stretch" gap={7}>

View File

@ -1,12 +1,5 @@
import { getFetchClient } from '@strapi/helper-plugin';
const fetchUser = async (id) => {
const { get } = getFetchClient();
const { data } = await get(`/admin/users/${id}`);
return data.data;
};
const putUser = async (id, body) => {
const { put } = getFetchClient();
@ -15,4 +8,4 @@ const putUser = async (id, body) => {
return data.data;
};
export { fetchUser, putUser };
export { putUser };

View File

@ -15,7 +15,6 @@ import {
Flex,
Typography,
} from '@strapi/design-system';
import { Formik } from 'formik';
import {
Form,
@ -24,20 +23,21 @@ import {
useOverlayBlocker,
useFetchClient,
} from '@strapi/helper-plugin';
import { useQueryClient, useMutation } from 'react-query';
import { useMutation } from 'react-query';
import formDataModel from 'ee_else_ce/pages/SettingsPage/pages/Users/ListPage/ModalForm/utils/formDataModel';
import roleSettingsForm from 'ee_else_ce/pages/SettingsPage/pages/Users/ListPage/ModalForm/utils/roleSettingsForm';
import MagicLink from 'ee_else_ce/pages/SettingsPage/pages/Users/components/MagicLink';
import SelectRoles from '../../components/SelectRoles';
import layout from './utils/layout';
import schema from './utils/schema';
import stepper from './utils/stepper';
const ModalForm = ({ queryName, onToggle }) => {
const ModalForm = ({ onSuccess, onToggle }) => {
const [currentStep, setStep] = useState('create');
const [isSubmitting, setIsSubmitting] = useState(false);
const [registrationToken, setRegistrationToken] = useState(null);
const queryClient = useQueryClient();
const { formatMessage } = useIntl();
const toggleNotification = useNotification();
const { lockApp, unlockApp } = useOverlayBlocker();
@ -50,8 +50,7 @@ const ModalForm = ({ queryName, onToggle }) => {
async onSuccess({ data }) {
setRegistrationToken(data.data.registrationToken);
await queryClient.refetchQueries(queryName);
await queryClient.refetchQueries(['ee', 'license-limit-info']);
await onSuccess();
goNext();
setIsSubmitting(false);
@ -216,7 +215,7 @@ const ModalForm = ({ queryName, onToggle }) => {
ModalForm.propTypes = {
onToggle: PropTypes.func.isRequired,
queryName: PropTypes.array.isRequired,
onSuccess: PropTypes.func.isRequired,
};
export default ModalForm;

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import qs from 'qs';
import {
DynamicTable,
SearchURLQuery,
@ -8,29 +9,28 @@ import {
useFocusWhenNavigate,
NoPermissions,
useAPIErrorHandler,
useFetchClient,
} from '@strapi/helper-plugin';
import {
ActionLayout,
ContentLayout,
HeaderLayout,
Main,
useNotifyAT,
} from '@strapi/design-system';
import { ActionLayout, ContentLayout, HeaderLayout, Main } from '@strapi/design-system';
import { useLocation } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { useMutation, useQueryClient } from 'react-query';
import CreateAction from 'ee_else_ce/pages/SettingsPage/pages/Users/ListPage/CreateAction';
import useLicenseLimitNotification from 'ee_else_ce/hooks/useLicenseLimitNotification';
import { useAdminUsers } from '../../../../../hooks/useAdminUsers';
import adminPermissions from '../../../../../permissions';
import TableRows from './DynamicTable/TableRows';
import Filters from '../../../components/Filters';
import ModalForm from './ModalForm';
import PaginationFooter from './PaginationFooter';
import { deleteData, fetchData } from './utils/api';
import displayedFilters from './utils/displayedFilters';
import tableHeaders from './utils/tableHeaders';
const EE_LICENSE_LIMIT_QUERY_KEY = ['ee', 'license-limit-info'];
const ListPage = () => {
const { post } = useFetchClient();
const { formatAPIError } = useAPIErrorHandler();
const [isModalOpened, setIsModalOpen] = useState(false);
const {
@ -42,8 +42,15 @@ const ListPage = () => {
const { search } = useLocation();
useFocusWhenNavigate();
useLicenseLimitNotification();
const { notifyStatus } = useNotifyAT();
const queryName = ['users', search];
const {
users,
pagination,
isError,
isLoading,
refetchQueries: refetchAdminUsers,
} = useAdminUsers(qs.parse(search, { ignoreQueryPrefix: true }), {
enabled: canRead,
});
const headers = tableHeaders.map((header) => ({
...header,
@ -58,59 +65,33 @@ const ListPage = () => {
defaultMessage: 'Users',
});
const notifyLoad = () => {
notifyStatus(
formatMessage(
{
id: 'app.utils.notify.data-loaded',
defaultMessage: 'The {target} has loaded',
},
{ target: title }
)
);
};
const { status, data, isFetching } = useQuery(queryName, () => fetchData(search, notifyLoad), {
enabled: canRead,
retry: false,
onError(error) {
toggleNotification({
type: 'warning',
message: {
id: 'notification.error',
message: formatAPIError(error),
defaultMessage: 'An error occured',
},
});
},
});
const handleToggle = () => {
setIsModalOpen((prev) => !prev);
};
const deleteAllMutation = useMutation((ids) => deleteData(ids), {
async onSuccess() {
await queryClient.refetchQueries(queryName);
// Toggle enabled/ disabled state on the invite button
await queryClient.refetchQueries(['ee', 'license-limit-info']);
const deleteAllMutation = useMutation(
async (ids) => {
await post('/admin/users/batch-delete', { ids });
},
onError(error) {
toggleNotification({
type: 'warning',
message: {
id: 'notification.error',
message: formatAPIError(error),
defaultMessage: 'An error occured',
},
});
},
});
{
async onSuccess() {
await refetchAdminUsers();
// This can be improved but we need to show an something to the user
const isLoading =
(status !== 'success' && status !== 'error') || (status === 'success' && isFetching);
// Toggle enabled/ disabled state on the invite button
await queryClient.refetchQueries(EE_LICENSE_LIMIT_QUERY_KEY);
},
onError(error) {
toggleNotification({
type: 'warning',
message: {
id: 'notification.error',
message: formatAPIError(error),
defaultMessage: 'An error occured',
},
});
},
}
);
return (
<Main aria-busy={isLoading}>
@ -141,7 +122,8 @@ const ListPage = () => {
<ContentLayout canRead={canRead}>
{!canRead && <NoPermissions />}
{status === 'error' && <div>TODO: An error occurred</div>}
{/* TODO: Replace error message with something better */}
{isError && <div>TODO: An error occurred</div>}
{canRead && (
<>
<DynamicTable
@ -150,23 +132,32 @@ const ListPage = () => {
onConfirmDeleteAll={deleteAllMutation.mutateAsync}
onConfirmDelete={(id) => deleteAllMutation.mutateAsync([id])}
headers={headers}
rows={data?.results}
rows={users}
withBulkActions
withMainAction={canDelete}
>
<TableRows
canDelete={canDelete}
headers={headers}
rows={data?.results || []}
rows={users}
withBulkActions
withMainAction={canDelete}
/>
</DynamicTable>
<PaginationFooter pagination={data?.pagination} />
{pagination && <PaginationFooter pagination={pagination} />}
</>
)}
</ContentLayout>
{isModalOpened && <ModalForm onToggle={handleToggle} queryName={queryName} />}
{isModalOpened && (
<ModalForm
onSuccess={async () => {
await refetchAdminUsers();
await queryClient.refetchQueries(EE_LICENSE_LIMIT_QUERY_KEY);
}}
onToggle={handleToggle}
/>
)}
</Main>
);
};

View File

@ -11,50 +11,49 @@ import Theme from '../../../../../../components/Theme';
import ThemeToggleProvider from '../../../../../../components/ThemeToggleProvider';
import ListPage from '../index';
jest.mock('../../../../../../hooks/useAdminUsers', () => ({
__esModule: true,
useAdminUsers: jest.fn().mockReturnValue({
users: [
{
email: 'soup@strapi.io',
firstname: 'soup',
id: 1,
isActive: true,
lastname: 'soupette',
roles: [
{
id: 1,
name: 'Super Admin',
},
],
},
{
email: 'dummy@strapi.io',
firstname: 'dummy',
id: 2,
isActive: false,
lastname: 'dum test',
roles: [
{
id: 1,
name: 'Super Admin',
},
{
id: 2,
name: 'Editor',
},
],
},
],
pagination: { page: 1, pageSize: 10, pageCount: 2, total: 2 },
isLoading: false,
isError: false,
}),
}));
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
getFetchClient: jest.fn(() => ({
get: jest.fn().mockReturnValue({
data: {
data: {
pagination: { page: 1, pageSize: 10, pageCount: 2, total: 2 },
results: [
{
email: 'soup@strapi.io',
firstname: 'soup',
id: 1,
isActive: true,
lastname: 'soupette',
roles: [
{
id: 1,
name: 'Super Admin',
},
],
},
{
email: 'dummy@strapi.io',
firstname: 'dummy',
id: 2,
isActive: false,
lastname: 'dum test',
roles: [
{
id: 1,
name: 'Super Admin',
},
{
id: 2,
name: 'Editor',
},
],
},
],
},
},
}),
put: jest.fn(),
})),
useNotification: jest.fn(),
useFocusWhenNavigate: jest.fn(),
useRBAC: jest.fn(() => ({

View File

@ -1,20 +0,0 @@
import { getFetchClient } from '@strapi/helper-plugin';
const fetchData = async (search, notify) => {
const { get } = getFetchClient();
const {
data: { data },
} = await get(`/admin/users${search}`);
notify();
return data;
};
const deleteData = async (ids) => {
const { post } = getFetchClient();
await post('/admin/users/batch-delete', { ids });
};
export { deleteData, fetchData };

View File

@ -2,24 +2,13 @@ import { useQuery } from 'react-query';
import { useNotification, useFetchClient } from '@strapi/helper-plugin';
import { useLocation } from 'react-router-dom';
import { useAdminUsers } from '../../../../../../../../admin/src/hooks/useAdminUsers';
const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
const { get } = useFetchClient();
const { search } = useLocation();
const toggleNotification = useNotification();
const fetchAuditLogsPage = async ({ queryKey }) => {
const search = queryKey[1];
const { data } = await get(`/admin/audit-logs${search}`);
return data;
};
const fetchAllUsers = async () => {
const { data } = await get(`/admin/users`);
return data;
};
const queryOptions = {
keepPreviousData: true,
retry: false,
@ -27,24 +16,43 @@ const useAuditLogsData = ({ canReadAuditLogs, canReadUsers }) => {
onError: (error) => toggleNotification({ type: 'warning', message: error.message }),
};
const {
users,
isError: isUsersError,
isLoading: isLoadingUsers,
} = useAdminUsers(
{},
{
...queryOptions,
enabled: canReadUsers,
staleTime: 2 * (1000 * 60), // 2 minutes
}
);
const {
data: auditLogs,
isLoading,
isLoading: isLoadingAuditLogs,
isError: isAuditLogsError,
} = useQuery(['auditLogs', search], fetchAuditLogsPage, {
...queryOptions,
enabled: canReadAuditLogs,
});
} = useQuery(
['auditLogs', search],
async ({ queryKey }) => {
const search = queryKey[1];
const { data } = await get(`/admin/audit-logs${search}`);
const { data: users, isError: isUsersError } = useQuery(['auditLogsUsers'], fetchAllUsers, {
...queryOptions,
enabled: canReadUsers,
staleTime: 2 * (1000 * 60), // 2 minutes
});
return data;
},
{
...queryOptions,
enabled: canReadAuditLogs,
}
);
const hasError = isAuditLogsError || isUsersError;
return { auditLogs, users: users?.data, isLoading, hasError };
return {
auditLogs,
users,
isLoading: isLoadingUsers || isLoadingAuditLogs,
hasError: isAuditLogsError || isUsersError,
};
};
export default useAuditLogsData;

View File

@ -1,23 +1,21 @@
import getDisplayedFilters from '../utils/getDisplayedFilters';
const mockUsers = {
results: [
{
id: 1,
firstname: 'test',
lastname: 'tester',
username: null,
email: 'test@test.com',
},
{
id: 2,
firstname: 'test2',
lastname: 'tester2',
username: null,
email: 'test2@test.com',
},
],
};
const mockUsers = [
{
id: 1,
firstname: 'test',
lastname: 'tester',
username: null,
email: 'test@test.com',
},
{
id: 2,
firstname: 'test2',
lastname: 'tester2',
username: null,
email: 'test2@test.com',
},
];
describe('Audit Logs getDisplayedFilters', () => {
it('should return all filters when canReadUsers is true', () => {

View File

@ -74,7 +74,7 @@ const getDisplayedFilters = ({ formatMessage, users, canReadUsers }) => {
return user.email;
};
const userOptions = users.results.map((user) => {
const userOptions = users.map((user) => {
return {
label: getDisplayNameFromUser(user),
// Combobox expects a string value