diff --git a/packages/core/admin/admin/src/content-manager/components/InputUID/index.js b/packages/core/admin/admin/src/content-manager/components/InputUID/index.js index cc9f3ee032..813c920c72 100644 --- a/packages/core/admin/admin/src/content-manager/components/InputUID/index.js +++ b/packages/core/admin/admin/src/content-manager/components/InputUID/index.js @@ -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} + */ + 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 ( - {availability && !regenerateLabel && ( + {availability && !showRegenerate && ( - {regenerateLabel && ( + {showRegenerate && ( - {regenerateLabel} + {formatMessage({ + id: 'content-manager.components.uid.regenerate', + defaultMessage: 'Regenerate', + })} )} 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 ? ( @@ -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 }; diff --git a/packages/core/admin/admin/src/content-manager/components/InputUID/tests/index.test.js b/packages/core/admin/admin/src/content-manager/components/InputUID/tests/index.test.js index c7ab09a8a3..02d9fc6c36 100644 --- a/packages/core/admin/admin/src/content-manager/components/InputUID/tests/index.test.js +++ b/packages/core/admin/admin/src/content-manager/components/InputUID/tests/index.test.js @@ -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( - , - { - wrapper({ children }) { - return ( - - - {children} - - - ); - }, - } - ); -} +const render = (props) => { + return { + ...renderRTL( + , + { + wrapper({ children }) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); -describe('Content-Manager | ', () => { + return ( + + + + {children} + + + + ); + }, + } + ), + user: userEvent.setup(), + }; +}; + +describe('InputUID', () => { beforeAll(() => { server.listen(); }); @@ -85,13 +99,15 @@ describe('Content-Manager | ', () => { }); 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 | ', () => { }); 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 | ', () => { 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 | ', () => { 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 | ', () => { 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 | ', () => { 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, diff --git a/packages/core/admin/admin/src/content-manager/components/Inputs/index.js b/packages/core/admin/admin/src/content-manager/components/Inputs/index.js index 5d1213007e..b8c92c5985 100644 --- a/packages/core/admin/admin/src/content-manager/components/Inputs/index.js +++ b/packages/core/admin/admin/src/content-manager/components/Inputs/index.js @@ -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';