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 { Flex, TextInput, Typography } from '@strapi/design-system';
import { import {
@ -10,16 +10,16 @@ import {
import { CheckCircle, ExclamationMarkCircle, Loader, Refresh } from '@strapi/icons'; import { CheckCircle, ExclamationMarkCircle, Loader, Refresh } from '@strapi/icons';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useMutation, useQuery } from 'react-query';
import useDebounce from '../../../hooks/useDebounce'; import useDebounce from '../../../hooks/useDebounce';
import { FieldActionWrapper, LoadingWrapper, TextValidation } from './endActionStyle'; import { FieldActionWrapper, LoadingWrapper, TextValidation } from './endActionStyle';
import UID_REGEX from './regex'; import UID_REGEX from './regex';
const InputUID = forwardRef( const InputUID = React.forwardRef(
( (
{ {
attribute,
contentTypeUID, contentTypeUID,
hint, hint,
disabled, disabled,
@ -34,20 +34,16 @@ const InputUID = forwardRef(
}, },
forwardedRef forwardedRef
) => { ) => {
const { modifiedData, initialData, layout } = useCMEditViewDataManager(); const [availability, setAvailability] = React.useState(null);
const [isLoading, setIsLoading] = useState(false); const [showRegenerate, setShowRegenerate] = React.useState(false);
const [availability, setAvailability] = useState(null); /**
* @type {string | null}
*/
const debouncedValue = useDebounce(value, 300); const debouncedValue = useDebounce(value, 300);
const generateUid = useRef(); const { modifiedData, initialData } = useCMEditViewDataManager();
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const { formatAPIError } = useAPIErrorHandler(); const { formatAPIError } = useAPIErrorHandler();
const initialValue = initialData[name];
const { formatMessage } = useIntl(); 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 { post } = useFetchClient();
const label = intlLabel.id 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 { const {
data: { data }, data: { data },
} = await post('/content-manager/uid/generate', { } = await post('/content-manager/uid/generate', body);
contentTypeUID,
field: name,
data: modifiedData,
});
onChange({ target: { name, value: data, type: 'text' } }, shouldSetInitialValue); return data;
setIsLoading(false); },
} catch (error) { onError(err) {
setIsLoading(false);
toggleNotification({ toggleNotification({
type: 'warning', 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 () => { const { mutate: generateUID, isLoading: isGeneratingUID } = useMutation({
if (!value) { async mutationFn(body) {
return; const {
} data: { data },
} = await post('/content-manager/uid/generate', body);
setIsLoading(true); return data;
},
try { onSuccess(data) {
const { data } = await post('/content-manager/uid/check-availability', { onChange({ target: { name, value: data, type: 'text' } });
contentTypeUID, },
field: name, onError(err) {
value: value ? value.trim() : '',
});
setIsLoading(false);
setAvailability(data);
} catch (error) {
setIsLoading(false);
toggleNotification({ toggleNotification({
type: 'warning', 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(() => { * @type {import('react-query').UseQueryResult<{ isAvailable: boolean }>
if (!value && attribute.required) { */
generateUid.current(true); const { data: availabilityData, isLoading: isCheckingAvailability } = useQuery({
} queryKey: [
}, [attribute.required, generateUid, value]); 'uid',
{ contentTypeUID, field: name, value: debouncedValue ? debouncedValue.trim() : '' },
],
async queryFn({ queryKey }) {
const [, body] = queryKey;
useEffect(() => { const { data } = await post('/content-manager/uid/check-availability', body);
if (debouncedValue?.trim().match(UID_REGEX) && debouncedValue !== initialValue) {
checkAvailability();
}
if (!debouncedValue) { return data;
setAvailability(null); },
} enabled: Boolean(
// eslint-disable-next-line react-hooks/exhaustive-deps debouncedValue !== initialData[name] &&
}, [initialValue, debouncedValue]); 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; let timer;
if (availability?.isAvailable) { if (availabilityData?.isAvailable) {
timer = setTimeout(() => { timer = setTimeout(() => {
setAvailability(null); setAvailability(null);
}, 4000); }, 4000);
@ -144,41 +160,9 @@ const InputUID = forwardRef(
clearTimeout(timer); clearTimeout(timer);
} }
}; };
}, [availability]); }, [availabilityData]);
useEffect(() => { const isLoading = isGeneratingDefaultUID || isGeneratingUID || isCheckingAvailability;
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);
};
return ( return (
<TextInput <TextInput
@ -187,7 +171,7 @@ const InputUID = forwardRef(
error={error} error={error}
endAction={ endAction={
<Flex position="relative" gap={1}> <Flex position="relative" gap={1}>
{availability && !regenerateLabel && ( {availability && !showRegenerate && (
<TextValidation <TextValidation
alignItems="center" alignItems="center"
gap={1} gap={1}
@ -222,22 +206,25 @@ const InputUID = forwardRef(
{!disabled && ( {!disabled && (
<> <>
{regenerateLabel && ( {showRegenerate && (
<TextValidation alignItems="center" justifyContent="flex-end" gap={1}> <TextValidation alignItems="center" justifyContent="flex-end" gap={1}>
<Typography textColor="primary600" variant="pi"> <Typography textColor="primary600" variant="pi">
{regenerateLabel} {formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})}
</Typography> </Typography>
</TextValidation> </TextValidation>
)} )}
<FieldActionWrapper <FieldActionWrapper
onClick={() => generateUid.current()} onClick={() => generateUID({ contentTypeUID, field: name, data: modifiedData })}
label={formatMessage({ label={formatMessage({
id: 'content-manager.components.uid.regenerate', id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate', defaultMessage: 'Regenerate',
})} })}
onMouseEnter={handleGenerateMouseEnter} onMouseEnter={() => setShowRegenerate(true)}
onMouseLeave={handleGenerateMouseLeave} onMouseLeave={() => setShowRegenerate(false)}
> >
{isLoading ? ( {isLoading ? (
<LoadingWrapper data-testid="loading-wrapper"> <LoadingWrapper data-testid="loading-wrapper">
@ -255,7 +242,7 @@ const InputUID = forwardRef(
label={label} label={label}
labelAction={labelAction} labelAction={labelAction}
name={name} name={name}
onChange={handleChange} onChange={onChange}
placeholder={formattedPlaceholder} placeholder={formattedPlaceholder}
value={value || ''} value={value || ''}
required={required} required={required}
@ -265,10 +252,6 @@ const InputUID = forwardRef(
); );
InputUID.propTypes = { InputUID.propTypes = {
attribute: PropTypes.shape({
targetField: PropTypes.string,
required: PropTypes.bool,
}).isRequired,
contentTypeUID: PropTypes.string.isRequired, contentTypeUID: PropTypes.string.isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,
error: PropTypes.string, error: PropTypes.string,
@ -300,4 +283,4 @@ InputUID.defaultProps = {
hint: '', hint: '',
}; };
export default InputUID; export { InputUID };

View File

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

View File

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