refactor(content-manager): convert InputUIDs data-fetching to use react-query (#18116)

This commit is contained in:
Josh 2023-09-21 16:15:43 +01:00 committed by GitHub
parent 07d4aac98e
commit 27166b42c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 165 additions and 163 deletions

View File

@ -1,4 +1,4 @@
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { Flex, TextInput, Typography } from '@strapi/design-system';
import {
@ -10,16 +10,16 @@ import {
import { CheckCircle, ExclamationMarkCircle, Loader, Refresh } from '@strapi/icons';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { useMutation, useQuery } from 'react-query';
import useDebounce from '../../../hooks/useDebounce';
import { FieldActionWrapper, LoadingWrapper, TextValidation } from './endActionStyle';
import UID_REGEX from './regex';
const InputUID = forwardRef(
const InputUID = React.forwardRef(
(
{
attribute,
contentTypeUID,
hint,
disabled,
@ -34,20 +34,16 @@ const InputUID = forwardRef(
},
forwardedRef
) => {
const { modifiedData, initialData, layout } = useCMEditViewDataManager();
const [isLoading, setIsLoading] = useState(false);
const [availability, setAvailability] = useState(null);
const [availability, setAvailability] = React.useState(null);
const [showRegenerate, setShowRegenerate] = React.useState(false);
/**
* @type {string | null}
*/
const debouncedValue = useDebounce(value, 300);
const generateUid = useRef();
const { modifiedData, initialData } = useCMEditViewDataManager();
const toggleNotification = useNotification();
const { formatAPIError } = useAPIErrorHandler();
const initialValue = initialData[name];
const { formatMessage } = useIntl();
const createdAtName = layout?.options?.timestamps ?? 0;
const isCreation = !initialData[createdAtName];
const debouncedTargetFieldValue = useDebounce(modifiedData[attribute.targetField], 300);
const [isCustomized, setIsCustomized] = useState(false);
const [regenerateLabel, setRegenerateLabel] = useState(null);
const { post } = useFetchClient();
const label = intlLabel.id
@ -64,76 +60,96 @@ const InputUID = forwardRef(
)
: '';
generateUid.current = async (shouldSetInitialValue = false) => {
setIsLoading(true);
/**
* @type {import('react-query').UseQueryResult<string>}
*/
const { data: defaultGeneratedUID, isLoading: isGeneratingDefaultUID } = useQuery({
queryKey: ['uid', { contentTypeUID, field: name, data: modifiedData }],
async queryFn({ queryKey }) {
const [, body] = queryKey;
try {
const {
data: { data },
} = await post('/content-manager/uid/generate', {
contentTypeUID,
field: name,
data: modifiedData,
});
} = await post('/content-manager/uid/generate', body);
onChange({ target: { name, value: data, type: 'text' } }, shouldSetInitialValue);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
return data;
},
onError(err) {
toggleNotification({
type: 'warning',
message: formatAPIError(error),
message: formatAPIError(err),
});
},
enabled: !value && required,
});
/**
* If the defaultGeneratedUID is available, then we set it as the value,
* but we also want to set it as the initialValue too.
*/
React.useEffect(() => {
if (defaultGeneratedUID) {
onChange({ target: { name, value: defaultGeneratedUID, type: 'text' } }, true);
}
};
}, [defaultGeneratedUID, name, onChange]);
const checkAvailability = async () => {
if (!value) {
return;
}
const { mutate: generateUID, isLoading: isGeneratingUID } = useMutation({
async mutationFn(body) {
const {
data: { data },
} = await post('/content-manager/uid/generate', body);
setIsLoading(true);
try {
const { data } = await post('/content-manager/uid/check-availability', {
contentTypeUID,
field: name,
value: value ? value.trim() : '',
});
setIsLoading(false);
setAvailability(data);
} catch (error) {
setIsLoading(false);
return data;
},
onSuccess(data) {
onChange({ target: { name, value: data, type: 'text' } });
},
onError(err) {
toggleNotification({
type: 'warning',
message: formatAPIError(error),
message: formatAPIError(err),
});
}
};
},
});
// FIXME: we need to find a better way to autofill the input when it is required.
useEffect(() => {
if (!value && attribute.required) {
generateUid.current(true);
}
}, [attribute.required, generateUid, value]);
/**
* @type {import('react-query').UseQueryResult<{ isAvailable: boolean }>
*/
const { data: availabilityData, isLoading: isCheckingAvailability } = useQuery({
queryKey: [
'uid',
{ contentTypeUID, field: name, value: debouncedValue ? debouncedValue.trim() : '' },
],
async queryFn({ queryKey }) {
const [, body] = queryKey;
useEffect(() => {
if (debouncedValue?.trim().match(UID_REGEX) && debouncedValue !== initialValue) {
checkAvailability();
}
const { data } = await post('/content-manager/uid/check-availability', body);
if (!debouncedValue) {
setAvailability(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValue, debouncedValue]);
return data;
},
enabled: Boolean(
debouncedValue !== initialData[name] &&
debouncedValue &&
UID_REGEX.test(debouncedValue.trim())
),
onError(err) {
toggleNotification({
type: 'warning',
message: formatAPIError(err),
});
},
});
React.useEffect(() => {
/**
* always store the data in state because that way as seen below
* we can then remove the data to stop showing the label.
*/
setAvailability(availabilityData);
useEffect(() => {
let timer;
if (availability?.isAvailable) {
if (availabilityData?.isAvailable) {
timer = setTimeout(() => {
setAvailability(null);
}, 4000);
@ -144,41 +160,9 @@ const InputUID = forwardRef(
clearTimeout(timer);
}
};
}, [availability]);
}, [availabilityData]);
useEffect(() => {
if (
!isCustomized &&
isCreation &&
debouncedTargetFieldValue &&
modifiedData[attribute.targetField] &&
!value
) {
generateUid.current(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedTargetFieldValue, isCustomized, isCreation]);
const handleGenerateMouseEnter = () => {
setRegenerateLabel(
formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})
);
};
const handleGenerateMouseLeave = () => {
setRegenerateLabel(null);
};
const handleChange = (e) => {
if (e.target.value && isCreation) {
setIsCustomized(true);
}
onChange(e);
};
const isLoading = isGeneratingDefaultUID || isGeneratingUID || isCheckingAvailability;
return (
<TextInput
@ -187,7 +171,7 @@ const InputUID = forwardRef(
error={error}
endAction={
<Flex position="relative" gap={1}>
{availability && !regenerateLabel && (
{availability && !showRegenerate && (
<TextValidation
alignItems="center"
gap={1}
@ -222,22 +206,25 @@ const InputUID = forwardRef(
{!disabled && (
<>
{regenerateLabel && (
{showRegenerate && (
<TextValidation alignItems="center" justifyContent="flex-end" gap={1}>
<Typography textColor="primary600" variant="pi">
{regenerateLabel}
{formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})}
</Typography>
</TextValidation>
)}
<FieldActionWrapper
onClick={() => generateUid.current()}
onClick={() => generateUID({ contentTypeUID, field: name, data: modifiedData })}
label={formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})}
onMouseEnter={handleGenerateMouseEnter}
onMouseLeave={handleGenerateMouseLeave}
onMouseEnter={() => setShowRegenerate(true)}
onMouseLeave={() => setShowRegenerate(false)}
>
{isLoading ? (
<LoadingWrapper data-testid="loading-wrapper">
@ -255,7 +242,7 @@ const InputUID = forwardRef(
label={label}
labelAction={labelAction}
name={name}
onChange={handleChange}
onChange={onChange}
placeholder={formattedPlaceholder}
value={value || ''}
required={required}
@ -265,10 +252,6 @@ const InputUID = forwardRef(
);
InputUID.propTypes = {
attribute: PropTypes.shape({
targetField: PropTypes.string,
required: PropTypes.bool,
}).isRequired,
contentTypeUID: PropTypes.string.isRequired,
disabled: PropTypes.bool,
error: PropTypes.string,
@ -300,4 +283,4 @@ InputUID.defaultProps = {
hint: '',
};
export default InputUID;
export { InputUID };

View File

@ -1,13 +1,15 @@
import React from 'react';
import { lightTheme, ThemeProvider } from '@strapi/design-system';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { NotificationsProvider } from '@strapi/helper-plugin';
import { render as renderRTL, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query';
import InputUID from '../index';
import { InputUID } from '../index';
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
@ -19,7 +21,6 @@ jest.mock('@strapi/helper-plugin', () => ({
name: 'initial-data',
},
})),
useNotification: jest.fn().mockReturnValue(() => {}),
}));
const server = setupServer(
@ -44,34 +45,47 @@ const server = setupServer(
})
);
function setup(props) {
return render(
<InputUID
attribute={{ targetField: 'target', required: true }}
contentTypeUID="api::test.test"
intlLabel={{
id: 'test',
defaultMessage: 'Label',
}}
name="name"
onChange={jest.fn()}
{...props}
/>,
{
wrapper({ children }) {
return (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
</ThemeProvider>
);
},
}
);
}
const render = (props) => {
return {
...renderRTL(
<InputUID
attribute={{ targetField: 'target', required: true }}
contentTypeUID="api::test.test"
intlLabel={{
id: 'test',
defaultMessage: 'Label',
}}
name="name"
onChange={jest.fn()}
{...props}
/>,
{
wrapper({ children }) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
describe('Content-Manager | <InputUID />', () => {
return (
<QueryClientProvider client={client}>
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}}>
<NotificationsProvider>{children}</NotificationsProvider>
</IntlProvider>
</ThemeProvider>
</QueryClientProvider>
);
},
}
),
user: userEvent.setup(),
};
};
describe('InputUID', () => {
beforeAll(() => {
server.listen();
});
@ -85,13 +99,15 @@ describe('Content-Manager | <InputUID />', () => {
});
test('renders', async () => {
const { getByText, getByRole } = await setup({
const { getByText, getByRole } = render({
hint: 'hint',
value: 'test',
required: true,
labelAction: <>action</>,
});
await waitFor(() => expect(getByText('Unavailable')).toBeInTheDocument());
expect(getByText('Label')).toBeInTheDocument();
expect(getByText('*')).toBeInTheDocument();
expect(getByText('action')).toBeInTheDocument();
@ -100,53 +116,56 @@ describe('Content-Manager | <InputUID />', () => {
});
test('renders an error', async () => {
const { getByText } = await setup({
const { getByText } = render({
value: 'test',
error: 'error',
});
await waitFor(() => expect(getByText('Unavailable')).toBeInTheDocument());
expect(getByText('error')).toBeInTheDocument();
});
test('Hides the regenerate label when disabled', async () => {
const { queryByRole } = await setup({ disabled: true, value: 'test' });
const { queryByRole, getByText } = render({ disabled: true, value: 'test' });
await waitFor(() => expect(getByText('Unavailable')).toBeInTheDocument());
expect(queryByRole('button', { name: /regenerate/i })).not.toBeInTheDocument();
});
test('Calls onChange handler', async () => {
const spy = jest.fn();
const { getByRole } = await setup({ value: 'test', onChange: spy });
const { getByRole, user } = render({ value: 'test', onChange: spy });
fireEvent.change(getByRole('textbox'), { target: { value: 'test-new' } });
const value = 'test-new';
expect(spy).toHaveBeenCalledTimes(1);
await user.type(getByRole('textbox'), value);
expect(spy).toHaveBeenCalledTimes(value.length);
});
test('Regenerates the value based on the target field', async () => {
const user = userEvent.setup();
const spy = jest.fn();
const { getByRole, queryByTestId } = await setup({ onChange: spy, value: '' });
const { getByRole, queryByTestId, user } = render({ onChange: spy, value: '' });
await user.click(getByRole('button', { name: /regenerate/i }));
await waitFor(() => expect(queryByTestId('loading-wrapper')).not.toBeInTheDocument());
expect(spy).toHaveBeenCalledWith(
{
target: {
name: 'name',
type: 'text',
value: 'source-string',
},
expect(spy).toHaveBeenCalledWith({
target: {
name: 'name',
type: 'text',
value: 'source-string',
},
true
);
});
});
test('If the field is required and the value is empty it should automatically fill it', async () => {
const spy = jest.fn();
const { queryByTestId } = await setup({
const { queryByTestId } = render({
value: '',
required: true,
onChange: spy,
@ -169,7 +188,7 @@ describe('Content-Manager | <InputUID />', () => {
test('If the field is required and the value is not empty it should not automatically fill it', async () => {
const spy = jest.fn();
const { queryByTestId } = await setup({
const { queryByTestId } = render({
value: 'test',
required: true,
onChange: spy,
@ -183,7 +202,7 @@ describe('Content-Manager | <InputUID />', () => {
test('Checks the initial availability (isAvailable)', async () => {
const spy = jest.fn();
const { getByText, queryByText, queryByTestId } = await setup({
const { getByText, queryByText, queryByTestId } = render({
value: 'available',
required: true,
onChange: spy,
@ -201,7 +220,7 @@ describe('Content-Manager | <InputUID />', () => {
test('Checks the initial availability (!isAvailable)', async () => {
const spy = jest.fn();
const { getByText, queryByTestId, queryByText } = await setup({
const { getByText, queryByTestId, queryByText } = render({
value: 'not-available',
required: true,
onChange: spy,
@ -219,7 +238,7 @@ describe('Content-Manager | <InputUID />', () => {
test('Does not check the initial availability without a value', async () => {
const spy = jest.fn();
const { queryByText, queryByTestId } = await setup({
const { queryByText, queryByTestId } = render({
value: '',
required: true,
onChange: spy,

View File

@ -10,7 +10,7 @@ import { useIntl } from 'react-intl';
import { useContentTypeLayout } from '../../hooks';
import { getFieldName } from '../../utils';
import InputUID from '../InputUID';
import { InputUID } from '../InputUID';
import { RelationInputDataManager } from '../RelationInputDataManager';
import Wysiwyg from '../Wysiwyg';