mirror of
https://github.com/strapi/strapi.git
synced 2025-07-31 12:55:08 +00:00
refactor(content-manager): convert InputUIDs data-fetching to use react-query (#18116)
This commit is contained in:
parent
07d4aac98e
commit
27166b42c0
@ -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 };
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user