Merge pull request #16141 from strapi/fix/input-uid-disabled

InputUID: Hide re-generate button when field is disabled
This commit is contained in:
Gustav Hansen 2023-03-22 12:01:31 +01:00 committed by GitHub
commit 9713fa429d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 299 additions and 358 deletions

View File

@ -1,9 +1,5 @@
import styled, { keyframes } from 'styled-components';
import { Box, Flex, FieldAction } from '@strapi/design-system';
export const EndActionWrapper = styled(Box)`
position: relative;
`;
import { Flex, FieldAction } from '@strapi/design-system';
export const FieldActionWrapper = styled(FieldAction)`
svg {
@ -22,18 +18,13 @@ export const FieldActionWrapper = styled(FieldAction)`
`;
export const TextValidation = styled(Flex)`
position: absolute;
right: ${({ theme }) => theme.spaces[6]};
width: 100px;
pointer-events: none;
svg {
margin-right: ${({ theme }) => theme.spaces[1]};
height: ${12 / 16}rem;
width: ${12 / 16}rem;
path {
fill: ${({ theme, notAvailable }) =>
!notAvailable ? theme.colors.success600 : theme.colors.danger600};
fill: ${({ theme, available }) =>
available ? theme.colors.success600 : theme.colors.danger600};
}
}
`;

View File

@ -1,19 +1,19 @@
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useCMEditViewDataManager, useFetchClient } from '@strapi/helper-plugin';
import {
useCMEditViewDataManager,
useFetchClient,
useNotification,
useAPIErrorHandler,
} from '@strapi/helper-plugin';
import { useIntl } from 'react-intl';
import get from 'lodash/get';
import { TextInput, Typography } from '@strapi/design-system';
import { Flex, TextInput, Typography } from '@strapi/design-system';
import { Refresh, CheckCircle, ExclamationMarkCircle, Loader } from '@strapi/icons';
import { getRequestUrl } from '../../utils';
import useDebounce from './useDebounce';
import UID_REGEX from './regex';
import {
EndActionWrapper,
FieldActionWrapper,
TextValidation,
LoadingWrapper,
} from './endActionStyle';
import { FieldActionWrapper, TextValidation, LoadingWrapper } from './endActionStyle';
const InputUID = ({
attribute,
@ -34,9 +34,11 @@ const InputUID = ({
const [availability, setAvailability] = useState(null);
const debouncedValue = useDebounce(value, 300);
const generateUid = useRef();
const toggleNotification = useNotification();
const { formatAPIError } = useAPIErrorHandler();
const initialValue = initialData[name];
const { formatMessage } = useIntl();
const createdAtName = get(layout, ['options', 'timestamps', 0]);
const createdAtName = layout?.options?.timestamps ?? 0;
const isCreation = !initialData[createdAtName];
const debouncedTargetFieldValue = useDebounce(modifiedData[attribute.targetField], 300);
const [isCustomized, setIsCustomized] = useState(false);
@ -59,72 +61,74 @@ const InputUID = ({
generateUid.current = async (shouldSetInitialValue = false) => {
setIsLoading(true);
const requestURL = getRequestUrl('uid/generate');
try {
const {
data: { data },
} = await post(requestURL, {
} = await post(getRequestUrl('uid/generate'), {
contentTypeUID,
field: name,
data: modifiedData,
});
onChange({ target: { name, value: data, type: 'text' } }, shouldSetInitialValue);
setIsLoading(false);
} catch (err) {
} catch (error) {
setIsLoading(false);
toggleNotification({
type: 'warning',
message: formatAPIError(error),
});
}
};
const checkAvailability = async () => {
setIsLoading(true);
const requestURL = getRequestUrl('uid/check-availability');
if (!value) {
return;
}
setIsLoading(true);
try {
const { data } = await post(requestURL, {
const { data } = await post(getRequestUrl('uid/check-availability'), {
contentTypeUID,
field: name,
value: value ? value.trim() : '',
});
setIsLoading(false);
setAvailability(data);
setIsLoading(false);
} catch (err) {
} catch (error) {
setIsLoading(false);
toggleNotification({
type: 'warning',
message: formatAPIError(error),
});
}
};
// // FIXME: we need to find a better way to autofill the input when it is required.
// FIXME: we need to find a better way to autofill the input when it is required.
useEffect(() => {
if (!value && attribute.required) {
generateUid.current(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [attribute.required, generateUid, value]);
useEffect(() => {
if (
debouncedValue &&
debouncedValue.trim().match(UID_REGEX) &&
debouncedValue !== initialValue
) {
if (debouncedValue?.trim().match(UID_REGEX) && debouncedValue !== initialValue) {
checkAvailability();
}
if (!debouncedValue) {
setAvailability(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue, initialValue]);
}, [initialValue, debouncedValue]);
useEffect(() => {
let timer;
if (availability && availability.isAvailable) {
if (availability?.isAvailable) {
timer = setTimeout(() => {
setAvailability(null);
}, 4000);
@ -176,51 +180,70 @@ const InputUID = ({
disabled={disabled}
error={error}
endAction={
<EndActionWrapper>
{availability && availability.isAvailable && !regenerateLabel && (
<TextValidation alignItems="center" justifyContent="flex-end">
<CheckCircle />
<Typography textColor="success600" variant="pi">
{formatMessage({
id: 'content-manager.components.uid.available',
defaultMessage: 'Available',
<Flex position="relative" gap={1}>
{availability && !regenerateLabel && (
<TextValidation
alignItems="center"
gap={1}
justifyContent="flex-end"
available={!!availability?.isAvailable}
data-not-here-outer
position="absolute"
pointerEvents="none"
right={6}
width="100px"
>
{availability?.isAvailable ? <CheckCircle /> : <ExclamationMarkCircle />}
<Typography
textColor={availability.isAvailable ? 'success600' : 'danger600'}
variant="pi"
>
{formatMessage(
availability.isAvailable
? {
id: 'content-manager.components.uid.available',
defaultMessage: 'Available',
}
: {
id: 'content-manager.components.uid.unavailable',
defaultMessage: 'Unavailable',
}
)}
</Typography>
</TextValidation>
)}
{!disabled && (
<>
{regenerateLabel && (
<TextValidation alignItems="center" justifyContent="flex-end" gap={1}>
<Typography textColor="primary600" variant="pi">
{regenerateLabel}
</Typography>
</TextValidation>
)}
<FieldActionWrapper
onClick={() => generateUid.current()}
label={formatMessage({
id: 'content-manager.components.uid.regenerate',
defaultMessage: 'Regenerate',
})}
</Typography>
</TextValidation>
onMouseEnter={handleGenerateMouseEnter}
onMouseLeave={handleGenerateMouseLeave}
>
{isLoading ? (
<LoadingWrapper data-testid="loading-wrapper">
<Loader />
</LoadingWrapper>
) : (
<Refresh />
)}
</FieldActionWrapper>
</>
)}
{availability && !availability.isAvailable && !regenerateLabel && (
<TextValidation notAvailable alignItems="center" justifyContent="flex-end">
<ExclamationMarkCircle />
<Typography textColor="danger600" variant="pi">
{formatMessage({
id: 'content-manager.components.uid.unavailable',
defaultMessage: 'Unavailable',
})}
</Typography>
</TextValidation>
)}
{regenerateLabel && (
<TextValidation alignItems="center" justifyContent="flex-end">
<Typography textColor="primary600" variant="pi">
{regenerateLabel}
</Typography>
</TextValidation>
)}
<FieldActionWrapper
onClick={() => generateUid.current()}
label="regenerate"
onMouseEnter={handleGenerateMouseEnter}
onMouseLeave={handleGenerateMouseLeave}
>
{isLoading ? (
<LoadingWrapper>
<Loader />
</LoadingWrapper>
) : (
<Refresh />
)}
</FieldActionWrapper>
</EndActionWrapper>
</Flex>
}
hint={hint}
label={label}

View File

@ -1,313 +1,240 @@
/**
*
* Tests for InputIUD
*
*/
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import InputUID from '../index';
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
useCMEditViewDataManager: jest.fn(() => ({
modifiedData: {},
initialData: {},
modifiedData: {
target: 'source-string',
},
initialData: {
name: 'initial-data',
},
})),
useNotification: jest.fn().mockReturnValue(() => {}),
}));
describe('<InputUID />', () => {
const props = {
attribute: {
required: false,
},
contentTypeUID: 'api::test.test',
intlLabel: {
id: 'test',
defaultMessage: 'test',
},
name: 'test',
onChange: jest.fn(),
value: 'michka',
};
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
it('renders and matches the snapshot', async () => {
const { container, getByText } = render(
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<InputUID {...props} />
</IntlProvider>
</ThemeProvider>
const server = setupServer(
rest.post('*/uid/generate', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.json({
data: body?.data?.target ?? 'regenerated',
})
);
}),
await waitFor(() => {
expect(getByText('test')).toBeInTheDocument();
rest.post('*/uid/check-availability', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.json({
isAvailable: body?.value === 'available',
})
);
})
);
const ComponentFixture = (props) => (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}}>
<InputUID
attribute={{ targetField: 'target', required: true }}
contentTypeUID="api::test.test"
intlLabel={{
id: 'test',
defaultMessage: 'Label',
}}
name="name"
onChange={jest.fn()}
{...props}
/>
</IntlProvider>
</ThemeProvider>
);
function setup(props) {
return new Promise((resolve) => {
act(() => {
resolve(render(<ComponentFixture {...props} />));
});
});
}
describe('Content-Manager | <InputUID />', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
test('renders', async () => {
const { getByText, getByRole } = await setup({
hint: 'hint',
value: 'test',
required: true,
labelAction: <>action</>,
});
expect(container.firstChild).toMatchInlineSnapshot(`
.c6 {
padding-right: 12px;
padding-left: 8px;
}
expect(getByText('Label')).toBeInTheDocument();
expect(getByText('*')).toBeInTheDocument();
expect(getByText('action')).toBeInTheDocument();
expect(getByText('hint')).toBeInTheDocument();
expect(getByRole('textbox')).toHaveValue('test');
});
.c8 {
background: transparent;
border-style: none;
}
test('renders an error', async () => {
const { getByText } = await setup({
error: 'error',
});
.c0 {
-webkit-align-items: stretch;
-webkit-box-align: stretch;
-ms-flex-align: stretch;
align-items: stretch;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
gap: 4px;
}
expect(getByText('error')).toBeInTheDocument();
});
.c3 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
}
test('Hides the regenerate label when disabled', async () => {
const { queryByRole } = await setup({ disabled: true, value: 'test' });
.c9 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-pack: unset;
-webkit-justify-content: unset;
-ms-flex-pack: unset;
justify-content: unset;
}
expect(queryByRole('button', { name: /regenerate/i })).not.toBeInTheDocument();
});
.c13 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
}
test('Calls onChange handler', async () => {
const spy = jest.fn();
const { getByRole } = await setup({ value: 'test', onChange: spy });
.c1 {
font-size: 0.75rem;
line-height: 1.33;
font-weight: 600;
color: #32324d;
}
fireEvent.change(getByRole('textbox'), { target: { value: 'test-new' } });
.c12 {
border: 0;
-webkit-clip: rect(0 0 0 0);
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
expect(spy).toHaveBeenCalledTimes(1);
});
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
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: '' });
.c5 {
border: none;
border-radius: 4px;
padding-bottom: 0.65625rem;
padding-left: 16px;
padding-right: 0;
padding-top: 0.65625rem;
color: #32324d;
font-weight: 400;
font-size: 0.875rem;
display: block;
width: 100%;
background: inherit;
}
await act(async () => {
await user.click(getByRole('button', { name: /regenerate/i }));
});
.c5::-webkit-input-placeholder {
color: #8e8ea9;
opacity: 1;
}
await waitFor(() => expect(queryByTestId('loading-wrapper')).not.toBeInTheDocument());
.c5::-moz-placeholder {
color: #8e8ea9;
opacity: 1;
}
expect(spy).toHaveBeenCalledWith(
{
target: {
name: 'name',
type: 'text',
value: 'source-string',
},
},
true
);
});
.c5:-ms-input-placeholder {
color: #8e8ea9;
opacity: 1;
}
test('If the field is required and the value is empty it should automatically fill it', async () => {
const spy = jest.fn();
.c5::placeholder {
color: #8e8ea9;
opacity: 1;
}
const { queryByTestId } = await setup({
value: '',
required: true,
onChange: spy,
});
.c5[aria-disabled='true'] {
color: inherit;
}
await waitFor(() => expect(queryByTestId('loading-wrapper')).not.toBeInTheDocument());
.c5:focus {
outline: none;
box-shadow: none;
}
expect(spy).toHaveBeenCalledWith(
{
target: {
name: 'name',
type: 'text',
value: 'source-string',
},
},
true
);
});
.c4 {
border: 1px solid #dcdce4;
border-radius: 4px;
background: #ffffff;
outline: none;
box-shadow: 0;
-webkit-transition-property: border-color,box-shadow,fill;
transition-property: border-color,box-shadow,fill;
-webkit-transition-duration: 0.2s;
transition-duration: 0.2s;
}
test('If the field is required and the value is not empty it should not automatically fill it', async () => {
const spy = jest.fn();
.c4:focus-within {
border: 1px solid #4945ff;
box-shadow: #4945ff 0px 0px 0px 2px;
}
const { queryByTestId } = await setup({
value: 'test',
required: true,
onChange: spy,
});
.c10 {
font-size: 1.6rem;
padding: 0;
}
await waitFor(() => expect(queryByTestId('loading-wrapper')).not.toBeInTheDocument());
.c7 {
position: relative;
}
expect(spy).not.toHaveBeenCalled();
});
.c11 svg {
height: 1rem;
width: 1rem;
}
test('Checks the initial availability (isAvailable)', async () => {
const spy = jest.fn();
.c11 svg path {
fill: #a5a5ba;
}
const { getByText, queryByText, queryByTestId } = await setup({
value: 'available',
required: true,
onChange: spy,
});
.c11 svg:hover path {
fill: #4945ff;
}
await waitFor(() => expect(queryByTestId('loading-wrapper')).not.toBeInTheDocument());
.c14 {
-webkit-animation: gzYjWD 2s infinite linear;
animation: gzYjWD 2s infinite linear;
}
expect(getByText('Available')).toBeInTheDocument();
<div>
<div
class=""
>
<div
class="c0"
>
<label
class="c1 c2"
for="1"
>
test
</label>
<div
class="c3 c4"
>
<input
aria-disabled="false"
aria-invalid="false"
aria-required="false"
class="c5"
id="1"
name="test"
placeholder=""
value="michka"
/>
<div
class="c6"
>
<div
class="c7"
>
<button
class="c8 c9 c10 c11"
type="button"
>
<span
class="c12"
>
regenerate
</span>
<div
aria-hidden="true"
class="c13 c14"
focusable="false"
>
<svg
fill="none"
height="1rem"
viewBox="0 0 24 24"
width="1rem"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12.057 18c.552 0 1 .451 1 .997v4.006a1 1 0 0 1-.941.995l-.059.002c-.552 0-1-.451-1-.997v-4.006a1 1 0 0 1 .941-.995l.06-.002Zm-3.06-.736.055.03c.478.276.64.89.367 1.364l-2.002 3.468a1 1 0 0 1-1.31.394l-.055-.03a1.002 1.002 0 0 1-.368-1.363l2.003-3.469a1 1 0 0 1 1.31-.394Zm7.42.394 2.002 3.468a1 1 0 0 1-.314 1.331l-.053.033a1.002 1.002 0 0 1-1.365-.363l-2.003-3.469a1 1 0 0 1 .314-1.33l.054-.034a1.002 1.002 0 0 1 1.364.364Zm-9.548-2.66.033.054c.276.478.11 1.091-.364 1.364L3.07 18.42a1 1 0 0 1-1.331-.314l-.033-.053a1.001 1.001 0 0 1 .364-1.365l3.468-2.003a1 1 0 0 1 1.33.314Zm11.79-.313 3.468 2.002a1 1 0 0 1 .393 1.31l-.03.055c-.276.478-.89.64-1.363.367l-3.469-2.003a1 1 0 0 1-.394-1.309l.03-.055c.276-.479.89-.64 1.364-.367Zm4.344-3.628a1 1 0 0 1 .995.941l.002.06c0 .551-.451 1-.997 1h-4.006a1 1 0 0 1-.995-.942L18 12.057c0-.552.451-1 .997-1h4.006Zm-18 0a1 1 0 0 1 .995.941l.002.06c0 .551-.451 1-.998 1H.998a1 1 0 0 1-.996-.942L0 12.057c0-.552.451-1 .998-1h4.004Zm17.454-5.059.033.054c.277.478.11 1.091-.363 1.365l-3.469 2.002a1 1 0 0 1-1.33-.314l-.034-.053a1.002 1.002 0 0 1 .364-1.365l3.468-2.003a1 1 0 0 1 1.331.314ZM3.07 5.684l3.468 2.003a1 1 0 0 1 .394 1.31l-.03.055c-.276.478-.89.64-1.364.367L2.07 7.417a1 1 0 0 1-.394-1.31l.03-.055c.276-.479.89-.64 1.364-.368Zm14.926-4.008.056.03c.478.276.64.89.367 1.364l-2.003 3.468a1 1 0 0 1-1.309.394l-.055-.03a1.002 1.002 0 0 1-.367-1.364l2.002-3.468a1 1 0 0 1 1.31-.394Zm-10.58.394L9.42 5.538a1 1 0 0 1-.314 1.33l-.053.034a1.002 1.002 0 0 1-1.365-.364L5.684 3.07a1 1 0 0 1 .314-1.331l.054-.033a1.002 1.002 0 0 1 1.365.364ZM12.058 0c.552 0 1 .451 1 .998v4.004a1 1 0 0 1-.941.996L12.057 6c-.552 0-1-.451-1-.998V.998a1 1 0 0 1 .941-.996l.06-.002Z"
fill="#212134"
fill-rule="evenodd"
/>
</svg>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`);
await sleep(4500);
expect(queryByText('Available')).not.toBeInTheDocument();
});
test('Checks the initial availability (!isAvailable)', async () => {
const spy = jest.fn();
const { getByText, queryByTestId, queryByText } = await setup({
value: 'not-available',
required: true,
onChange: spy,
});
await waitFor(() => expect(queryByTestId('loading-wrapper')).not.toBeInTheDocument());
expect(getByText('Unavailable')).toBeInTheDocument();
await sleep(4500);
expect(queryByText('Available')).not.toBeInTheDocument();
});
test('Does not check the initial availability without a value', async () => {
const spy = jest.fn();
const { queryByText, queryByTestId } = await setup({
value: '',
required: true,
onChange: spy,
});
await waitFor(() => expect(queryByTestId('loading-wrapper')).not.toBeInTheDocument());
expect(queryByText('Available')).not.toBeInTheDocument();
expect(queryByText('Unavailable')).not.toBeInTheDocument();
});
});