feat: add dynamic components above and below (#16826)

Co-authored-by: Gustav Hansen <gustav.hansen@strapi.io>
This commit is contained in:
Josh 2023-06-05 11:14:30 +01:00 committed by GitHub
parent 6bd5d14d47
commit aba64850a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1070 additions and 790 deletions

View File

@ -6,14 +6,41 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import styled from 'styled-components'; import styled from 'styled-components';
import { PlusCircle } from '@strapi/icons'; import { PlusCircle } from '@strapi/icons';
import { BaseButton, Box, Flex, Typography } from '@strapi/design-system'; import { BaseButton, Flex, Typography } from '@strapi/design-system';
import { getTrad } from '../../../utils'; export const AddComponentButton = ({ hasError, isDisabled, isOpen, children, onClick }) => {
return (
<StyledButton
type="button"
onClick={onClick}
disabled={isDisabled}
hasError={hasError}
background="neutral0"
paddingTop={3}
paddingBottom={3}
paddingLeft={4}
paddingRight={4}
style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}
>
<Flex as="span" gap={2}>
<StyledAddIcon aria-hidden $isOpen={isOpen} $hasError={hasError && !isOpen} />
<Typography
variant="pi"
fontWeight="bold"
textColor={hasError && !isOpen ? 'danger600' : 'neutral500'}
>
{children}
</Typography>
</Flex>
</StyledButton>
);
};
const StyledAddIcon = styled(PlusCircle)` const StyledAddIcon = styled(PlusCircle)`
height: ${({ theme }) => theme.spaces[6]};
width: ${({ theme }) => theme.spaces[6]};
transform: ${({ $isOpen }) => ($isOpen ? 'rotate(45deg)' : 'rotate(0deg)')}; transform: ${({ $isOpen }) => ($isOpen ? 'rotate(45deg)' : 'rotate(0deg)')};
> circle { > circle {
fill: ${({ theme, $hasError }) => fill: ${({ theme, $hasError }) =>
@ -28,25 +55,11 @@ const StyledAddIcon = styled(PlusCircle)`
const StyledButton = styled(BaseButton)` const StyledButton = styled(BaseButton)`
border-radius: 26px; border-radius: 26px;
border-color: ${({ theme }) => theme.colors.neutral150}; border-color: ${({ theme }) => theme.colors.neutral150};
background: ${({ theme }) => theme.colors.neutral0};
padding-top: ${({ theme }) => theme.spaces[3]};
padding-right: ${({ theme }) => theme.spaces[4]};
padding-bottom: ${({ theme }) => theme.spaces[3]};
padding-left: ${({ theme }) => theme.spaces[4]};
box-shadow: ${({ theme }) => theme.shadows.filterShadow}; box-shadow: ${({ theme }) => theme.shadows.filterShadow};
svg {
height: ${({ theme }) => theme.spaces[6]};
width: ${({ theme }) => theme.spaces[6]};
> path {
fill: ${({ theme }) => theme.colors.neutral600};
}
}
&:hover { &:hover {
color: ${({ theme }) => theme.colors.primary600} !important;
${Typography} { ${Typography} {
color: ${({ theme }) => theme.colors.primary600} !important; color: ${({ theme }) => theme.colors.primary600};
} }
${StyledAddIcon} { ${StyledAddIcon} {
@ -73,92 +86,16 @@ const StyledButton = styled(BaseButton)`
} }
`; `;
const BoxFullHeight = styled(Box)`
height: 100%;
`;
const AddComponentButton = ({
hasError,
hasMaxError,
hasMinError,
isDisabled,
isOpen,
label,
missingComponentNumber,
name,
onClick,
}) => {
const { formatMessage } = useIntl();
const addLabel = formatMessage(
{
id: getTrad('components.DynamicZone.add-component'),
defaultMessage: 'Add a component to {componentName}',
},
{ componentName: label || name }
);
const closeLabel = formatMessage({ id: 'app.utils.close-label', defaultMessage: 'Close' });
let buttonLabel = isOpen ? closeLabel : addLabel;
if (hasMaxError && !isOpen) {
buttonLabel = formatMessage({
id: 'components.Input.error.validation.max',
defaultMessage: 'The value is too high.',
});
}
if (hasMinError && !isOpen) {
buttonLabel = formatMessage(
{
id: getTrad(`components.DynamicZone.missing-components`),
defaultMessage:
'There {number, plural, =0 {are # missing components} one {is # missing component} other {are # missing components}}',
},
{ number: missingComponentNumber }
);
}
return (
<Flex justifyContent="center">
<Box style={{ cursor: isDisabled ? 'not-allowed' : 'pointer' }}>
<StyledButton type="button" onClick={onClick} disabled={isDisabled} hasError={hasError}>
<Flex>
<BoxFullHeight aria-hidden paddingRight={2}>
<StyledAddIcon $isOpen={isOpen} $hasError={hasError && !isOpen} />
</BoxFullHeight>
<Typography
variant="pi"
fontWeight="bold"
textColor={hasError && !isOpen ? 'danger600' : 'neutral500'}
>
{buttonLabel}
</Typography>
</Flex>
</StyledButton>
</Box>
</Flex>
);
};
AddComponentButton.defaultProps = { AddComponentButton.defaultProps = {
hasError: false, hasError: false,
hasMaxError: false,
hasMinError: false,
isDisabled: false, isDisabled: false,
isOpen: false, isOpen: false,
label: '',
missingComponentNumber: 0,
}; };
AddComponentButton.propTypes = { AddComponentButton.propTypes = {
label: PropTypes.string, children: PropTypes.node.isRequired,
hasError: PropTypes.bool, hasError: PropTypes.bool,
hasMaxError: PropTypes.bool,
hasMinError: PropTypes.bool,
isDisabled: PropTypes.bool, isDisabled: PropTypes.bool,
isOpen: PropTypes.bool, isOpen: PropTypes.bool,
missingComponentNumber: PropTypes.number,
name: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
}; };
export default AddComponentButton;

View File

@ -1,64 +0,0 @@
/**
*
* ComponentCard
*
*/
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { Box, Typography, Flex } from '@strapi/design-system';
import { pxToRem } from '@strapi/helper-plugin';
import { ComponentIcon } from '../../ComponentIcon';
const ComponentBox = styled(Box)`
flex-shrink: 0;
height: ${pxToRem(84)};
border: 1px solid ${({ theme }) => theme.colors.neutral200};
background: ${({ theme }) => theme.colors.neutral100};
border-radius: ${({ theme }) => theme.borderRadius};
display: flex;
justify-content: center;
align-items: center;
&:focus,
&:hover {
border: 1px solid ${({ theme }) => theme.colors.primary200};
background: ${({ theme }) => theme.colors.primary100};
${Typography} {
color: ${({ theme }) => theme.colors.primary600};
}
/* > Flex > ComponentIcon */
> div > div:first-child {
background: ${({ theme }) => theme.colors.primary200};
color: ${({ theme }) => theme.colors.primary600};
}
}
`;
export default function ComponentCard({ children, onClick }) {
return (
<ComponentBox as="button" type="button" onClick={onClick} hasRadius>
<Flex direction="column" gap={1} alignItems="center" justifyContent="center">
<ComponentIcon />
<Typography variant="pi" fontWeight="bold" textColor="neutral600">
{children}
</Typography>
</Flex>
</ComponentBox>
);
}
ComponentCard.defaultProps = {
onClick() {},
};
ComponentCard.propTypes = {
children: PropTypes.node.isRequired,
onClick: PropTypes.func,
};

View File

@ -1,18 +1,27 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Accordion, AccordionToggle, AccordionContent, Box } from '@strapi/design-system'; import {
Accordion,
AccordionToggle,
AccordionContent,
Box,
Flex,
Typography,
} from '@strapi/design-system';
import { pxToRem } from '@strapi/helper-plugin';
import styled from 'styled-components'; import styled from 'styled-components';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import ComponentCard from './ComponentCard'; import { ComponentIcon } from '../../ComponentIcon';
const Grid = styled.div` export const ComponentCategory = ({
display: grid; category,
grid-template-columns: repeat(auto-fit, ${140 / 16}rem); components,
grid-gap: ${({ theme }) => theme.spaces[1]}; variant,
`; isOpen,
onAddComponent,
const ComponentCategory = ({ category, components, variant, isOpen, onAddComponent, onToggle }) => { onToggle,
}) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const handleToggle = () => { const handleToggle = () => {
@ -30,9 +39,26 @@ const ComponentCategory = ({ category, components, variant, isOpen, onAddCompone
<Box paddingTop={4} paddingBottom={4} paddingLeft={3} paddingRight={3}> <Box paddingTop={4} paddingBottom={4} paddingLeft={3} paddingRight={3}>
<Grid> <Grid>
{components.map(({ componentUid, info: { displayName } }) => ( {components.map(({ componentUid, info: { displayName } }) => (
<ComponentCard key={componentUid} onClick={onAddComponent(componentUid)}> <ComponentBox
{formatMessage({ id: displayName, defaultMessage: displayName })} key={componentUid}
</ComponentCard> as="button"
type="button"
background="neutral100"
justifyContent="center"
onClick={onAddComponent(componentUid)}
hasRadius
height={pxToRem(84)}
shrink={0}
borderColor="neutral200"
>
<Flex direction="column" gap={1} alignItems="center" justifyContent="center">
<ComponentIcon />
<Typography variant="pi" fontWeight="bold" textColor="neutral600">
{formatMessage({ id: displayName, defaultMessage: displayName })}
</Typography>
</Flex>
</ComponentBox>
))} ))}
</Grid> </Grid>
</Box> </Box>
@ -41,6 +67,30 @@ const ComponentCategory = ({ category, components, variant, isOpen, onAddCompone
); );
}; };
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, ${140 / 16}rem);
grid-gap: ${({ theme }) => theme.spaces[1]};
`;
const ComponentBox = styled(Flex)`
&:focus,
&:hover {
border: 1px solid ${({ theme }) => theme.colors.primary200};
background: ${({ theme }) => theme.colors.primary100};
${Typography} {
color: ${({ theme }) => theme.colors.primary600};
}
/* > Flex > ComponentIcon */
> div > div:first-child {
background: ${({ theme }) => theme.colors.primary200};
color: ${({ theme }) => theme.colors.primary600};
}
}
`;
ComponentCategory.defaultProps = { ComponentCategory.defaultProps = {
components: [], components: [],
isOpen: false, isOpen: false,
@ -55,5 +105,3 @@ ComponentCategory.propTypes = {
onToggle: PropTypes.func.isRequired, onToggle: PropTypes.func.isRequired,
variant: PropTypes.oneOf(['primary', 'secondary']), variant: PropTypes.oneOf(['primary', 'secondary']),
}; };
export default ComponentCategory;

View File

@ -1,40 +1,24 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState } from 'react';
import groupBy from 'lodash/groupBy';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { KeyboardNavigable, Box, Flex, Typography } from '@strapi/design-system'; import { KeyboardNavigable, Box, Flex, Typography } from '@strapi/design-system';
import { getTrad } from '../../../utils'; import { getTrad } from '../../../utils';
import { useContentTypeLayout } from '../../../hooks';
import ComponentCategory from './ComponentCategory'; import { ComponentCategory } from './ComponentCategory';
const ComponentPicker = ({ components, isOpen, onClickAddComponent }) => { export const ComponentPicker = ({ dynamicComponentsByCategory, isOpen, onClickAddComponent }) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { getComponentLayout } = useContentTypeLayout();
const [categoryToOpen, setCategoryToOpen] = useState(''); const [categoryToOpen, setCategoryToOpen] = useState('');
const dynamicComponentCategories = useMemo(() => {
const componentsWithInfo = components.map((componentUid) => {
const { category, info } = getComponentLayout(componentUid);
return { componentUid, category, info };
});
const categories = groupBy(componentsWithInfo, 'category');
return Object.keys(categories).reduce((acc, current) => {
acc.push({ category: current, components: categories[current] });
return acc;
}, []);
}, [components, getComponentLayout]);
useEffect(() => { useEffect(() => {
if (isOpen && dynamicComponentCategories.length > 0) { const categoryKeys = Object.keys(dynamicComponentsByCategory);
setCategoryToOpen(dynamicComponentCategories[0].category);
if (isOpen && categoryKeys.length > 0) {
setCategoryToOpen(categoryKeys[0]);
} }
}, [isOpen, dynamicComponentCategories]); }, [isOpen, dynamicComponentsByCategory]);
const handleAddComponentToDz = (componentUid) => () => { const handleAddComponentToDz = (componentUid) => () => {
onClickAddComponent(componentUid); onClickAddComponent(componentUid);
@ -53,54 +37,57 @@ const ComponentPicker = ({ components, isOpen, onClickAddComponent }) => {
} }
return ( return (
<Box paddingBottom={6}> <Box
<Box paddingTop={6}
paddingTop={6} paddingBottom={6}
paddingBottom={6} paddingLeft={5}
paddingLeft={5} paddingRight={5}
paddingRight={5} background="neutral0"
background="neutral0" shadow="tableShadow"
shadow="tableShadow" borderColor="neutral150"
borderColor="neutral150" hasRadius
hasRadius >
> <Flex justifyContent="center">
<Flex justifyContent="center"> <Typography fontWeight="bold" textColor="neutral600">
<Typography fontWeight="bold" textColor="neutral600"> {formatMessage({
{formatMessage({ id: getTrad('components.DynamicZone.ComponentPicker-label'),
id: getTrad('components.DynamicZone.ComponentPicker-label'), defaultMessage: 'Pick one component',
defaultMessage: 'Pick one component', })}
})} </Typography>
</Typography> </Flex>
</Flex> <Box paddingTop={2}>
<Box paddingTop={2}> <KeyboardNavigable attributeName="data-strapi-accordion-toggle">
<KeyboardNavigable attributeName="data-strapi-accordion-toggle"> {Object.entries(dynamicComponentsByCategory).map(([category, components], index) => (
{dynamicComponentCategories.map(({ category, components }, index) => ( <ComponentCategory
<ComponentCategory key={category}
key={category} category={category}
category={category} components={components}
components={components} onAddComponent={handleAddComponentToDz}
onAddComponent={handleAddComponentToDz} isOpen={category === categoryToOpen}
isOpen={category === categoryToOpen} onToggle={handleClickToggle}
onToggle={handleClickToggle} variant={index % 2 === 1 ? 'primary' : 'secondary'}
variant={index % 2 === 1 ? 'primary' : 'secondary'} />
/> ))}
))} </KeyboardNavigable>
</KeyboardNavigable>
</Box>
</Box> </Box>
</Box> </Box>
); );
}; };
ComponentPicker.defaultProps = { ComponentPicker.defaultProps = {
components: [], dynamicComponentsByCategory: {},
isOpen: false, isOpen: false,
}; };
ComponentPicker.propTypes = { ComponentPicker.propTypes = {
components: PropTypes.array, dynamicComponentsByCategory: PropTypes.shape({
components: PropTypes.arrayOf(
PropTypes.shape({
componentUid: PropTypes.string.isRequired,
info: PropTypes.object,
})
),
}),
isOpen: PropTypes.bool, isOpen: PropTypes.bool,
onClickAddComponent: PropTypes.func.isRequired, onClickAddComponent: PropTypes.func.isRequired,
}; };
export default ComponentPicker;

View File

@ -12,67 +12,18 @@ import {
IconButton, IconButton,
Box, Box,
Flex, Flex,
VisuallyHidden,
} from '@strapi/design-system'; } from '@strapi/design-system';
import { Menu, MenuItem } from '@strapi/design-system/v2';
import { useCMEditViewDataManager } from '@strapi/helper-plugin'; import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import { Trash, Drag } from '@strapi/icons'; import { Trash, Drag, More } from '@strapi/icons';
import { useContentTypeLayout, useDragAndDrop } from '../../../hooks'; import { useContentTypeLayout, useDragAndDrop } from '../../../hooks';
import { composeRefs, getTrad, ItemTypes } from '../../../utils'; import { composeRefs, getTrad, ItemTypes } from '../../../utils';
import FieldComponent from '../../FieldComponent'; import FieldComponent from '../../FieldComponent';
const ActionsFlex = styled(Flex)` export const DynamicComponent = ({
/*
we need to remove the background from the button but we can't
wrap the element in styled because it breaks the forwardedAs which
we need for drag handler to work on firefox
*/
div[role='button'] {
background: transparent;
}
`;
const IconButtonCustom = styled(IconButton)`
background-color: transparent;
svg path {
fill: ${({ theme, expanded }) =>
expanded ? theme.colors.primary600 : theme.colors.neutral600};
}
`;
// TODO: Delete once https://github.com/strapi/design-system/pull/858
// is merged and released.
const StyledBox = styled(Box)`
> div:first-child {
box-shadow: ${({ theme }) => theme.shadows.tableShadow};
}
`;
const AccordionContentRadius = styled(Box)`
border-radius: 0 0 ${({ theme }) => theme.spaces[1]} ${({ theme }) => theme.spaces[1]};
`;
const Rectangle = styled(Box)`
width: ${({ theme }) => theme.spaces[2]};
height: ${({ theme }) => theme.spaces[4]};
`;
const Preview = styled.span`
display: block;
background-color: ${({ theme }) => theme.colors.primary100};
outline: 1px dashed ${({ theme }) => theme.colors.primary500};
outline-offset: -1px;
padding: ${({ theme }) => theme.spaces[6]};
`;
const ComponentContainer = styled(Box)`
list-style: none;
padding: 0;
margin: 0;
`;
const DynamicZoneComponent = ({
componentUid, componentUid,
formErrors, formErrors,
index, index,
@ -83,6 +34,8 @@ const DynamicZoneComponent = ({
onGrabItem, onGrabItem,
onDropItem, onDropItem,
onCancel, onCancel,
dynamicComponentsByCategory,
onAddComponent,
}) => { }) => {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
@ -180,11 +133,70 @@ const DynamicZoneComponent = ({
> >
<Drag /> <Drag />
</IconButton> </IconButton>
<Menu.Root>
<Menu.Trigger size="S" endIcon={undefined} paddingLeft={2} paddingRight={2}>
<More aria-hidden focusable={false} />
<VisuallyHidden as="span">
{formatMessage({
id: getTrad('components.DynamicZone.more-actions'),
defaultMessage: 'More actions',
})}
</VisuallyHidden>
</Menu.Trigger>
<Menu.Content>
<Menu.SubRoot>
<Menu.SubTrigger>
{formatMessage({
id: getTrad('components.DynamicZone.add-item-above'),
defaultMessage: 'Add component above',
})}
</Menu.SubTrigger>
<Menu.SubContent>
{Object.entries(dynamicComponentsByCategory).map(([category, components]) => (
<React.Fragment key={category}>
<Menu.Label>{category}</Menu.Label>
{components.map(({ componentUid, info: { displayName } }) => (
<MenuItem
key={componentUid}
onSelect={() => onAddComponent(componentUid, index)}
>
{displayName}
</MenuItem>
))}
</React.Fragment>
))}
</Menu.SubContent>
</Menu.SubRoot>
<Menu.SubRoot>
<Menu.SubTrigger>
{formatMessage({
id: getTrad('components.DynamicZone.add-item-below'),
defaultMessage: 'Add component below',
})}
</Menu.SubTrigger>
<Menu.SubContent>
{Object.entries(dynamicComponentsByCategory).map(([category, components]) => (
<React.Fragment key={category}>
<Menu.Label>{category}</Menu.Label>
{components.map(({ componentUid, info: { displayName } }) => (
<MenuItem
key={componentUid}
onSelect={() => onAddComponent(componentUid, index + 1)}
>
{displayName}
</MenuItem>
))}
</React.Fragment>
))}
</Menu.SubContent>
</Menu.SubRoot>
</Menu.Content>
</Menu.Root>
</ActionsFlex> </ActionsFlex>
); );
return ( return (
<ComponentContainer as="li"> <ComponentContainer as="li" width="100%">
<Flex justifyContent="center"> <Flex justifyContent="center">
<Rectangle background="neutral200" /> <Rectangle background="neutral200" />
</Flex> </Flex>
@ -215,26 +227,86 @@ const DynamicZoneComponent = ({
); );
}; };
DynamicZoneComponent.defaultProps = { const ActionsFlex = styled(Flex)`
/*
we need to remove the background from the button but we can't
wrap the element in styled because it breaks the forwardedAs which
we need for drag handler to work on firefox
*/
div[role='button'] {
background: transparent;
}
`;
const IconButtonCustom = styled(IconButton)`
background-color: transparent;
svg path {
fill: ${({ theme, expanded }) =>
expanded ? theme.colors.primary600 : theme.colors.neutral600};
}
`;
// TODO: Delete once https://github.com/strapi/design-system/pull/858
// is merged and released.
const StyledBox = styled(Box)`
> div:first-child {
box-shadow: ${({ theme }) => theme.shadows.tableShadow};
}
`;
const AccordionContentRadius = styled(Box)`
border-radius: 0 0 ${({ theme }) => theme.spaces[1]} ${({ theme }) => theme.spaces[1]};
`;
const Rectangle = styled(Box)`
width: ${({ theme }) => theme.spaces[2]};
height: ${({ theme }) => theme.spaces[4]};
`;
const Preview = styled.span`
display: block;
background-color: ${({ theme }) => theme.colors.primary100};
outline: 1px dashed ${({ theme }) => theme.colors.primary500};
outline-offset: -1px;
padding: ${({ theme }) => theme.spaces[6]};
`;
const ComponentContainer = styled(Box)`
list-style: none;
padding: 0;
margin: 0;
`;
DynamicComponent.defaultProps = {
dynamicComponentsByCategory: {},
formErrors: {}, formErrors: {},
index: 0, index: 0,
isFieldAllowed: true, isFieldAllowed: true,
onAddComponent: undefined,
onGrabItem: undefined, onGrabItem: undefined,
onDropItem: undefined, onDropItem: undefined,
onCancel: undefined, onCancel: undefined,
}; };
DynamicZoneComponent.propTypes = { DynamicComponent.propTypes = {
componentUid: PropTypes.string.isRequired, componentUid: PropTypes.string.isRequired,
dynamicComponentsByCategory: PropTypes.shape({
components: PropTypes.arrayOf(
PropTypes.shape({
componentUid: PropTypes.string.isRequired,
info: PropTypes.object,
})
),
}),
formErrors: PropTypes.object, formErrors: PropTypes.object,
index: PropTypes.number, index: PropTypes.number,
isFieldAllowed: PropTypes.bool, isFieldAllowed: PropTypes.bool,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
onAddComponent: PropTypes.func,
onGrabItem: PropTypes.func, onGrabItem: PropTypes.func,
onDropItem: PropTypes.func, onDropItem: PropTypes.func,
onCancel: PropTypes.func, onCancel: PropTypes.func,
onMoveComponent: PropTypes.func.isRequired, onMoveComponent: PropTypes.func.isRequired,
onRemoveComponentClick: PropTypes.func.isRequired, onRemoveComponentClick: PropTypes.func.isRequired,
}; };
export default DynamicZoneComponent;

View File

@ -7,15 +7,10 @@
import React from 'react'; import React from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import styled from 'styled-components';
import { pxToRem } from '@strapi/helper-plugin'; import { pxToRem } from '@strapi/helper-plugin';
import { Box, Flex, Typography } from '@strapi/design-system'; import { Box, Flex, Typography } from '@strapi/design-system';
const StyledBox = styled(Box)` export const DynamicZoneLabel = ({
border-radius: ${pxToRem(26)};
`;
const DynamicZoneLabel = ({
label, label,
labelAction, labelAction,
name, name,
@ -28,36 +23,35 @@ const DynamicZoneLabel = ({
return ( return (
<Flex justifyContent="center"> <Flex justifyContent="center">
<Box> <Box
<StyledBox paddingTop={3}
paddingTop={3} paddingBottom={3}
paddingBottom={3} paddingRight={4}
paddingRight={4} paddingLeft={4}
paddingLeft={4} borderRadius={26}
background="neutral0" background="neutral0"
shadow="filterShadow" shadow="filterShadow"
color="neutral500" color="neutral500"
> >
<Flex direction="column" justifyContent="center"> <Flex direction="column" justifyContent="center">
<Flex maxWidth={pxToRem(356)}> <Flex maxWidth={pxToRem(356)}>
<Typography variant="pi" textColor="neutral600" fontWeight="bold" ellipsis> <Typography variant="pi" textColor="neutral600" fontWeight="bold" ellipsis>
{intlLabel}&nbsp; {intlLabel}&nbsp;
</Typography> </Typography>
<Typography variant="pi" textColor="neutral600" fontWeight="bold"> <Typography variant="pi" textColor="neutral600" fontWeight="bold">
({numberOfComponents}) ({numberOfComponents})
</Typography> </Typography>
{required && <Typography textColor="danger600">*</Typography>} {required && <Typography textColor="danger600">*</Typography>}
{labelAction && <Box paddingLeft={1}>{labelAction}</Box>} {labelAction && <Box paddingLeft={1}>{labelAction}</Box>}
</Flex>
{intlDescription && (
<Box paddingTop={1} maxWidth={pxToRem(356)}>
<Typography variant="pi" textColor="neutral600" ellipsis>
{formatMessage(intlDescription)}
</Typography>
</Box>
)}
</Flex> </Flex>
</StyledBox> {intlDescription && (
<Box paddingTop={1} maxWidth={pxToRem(356)}>
<Typography variant="pi" textColor="neutral600" ellipsis>
{formatMessage(intlDescription)}
</Typography>
</Box>
)}
</Flex>
</Box> </Box>
</Flex> </Flex>
); );
@ -82,5 +76,3 @@ DynamicZoneLabel.propTypes = {
numberOfComponents: PropTypes.number, numberOfComponents: PropTypes.number,
required: PropTypes.bool, required: PropTypes.bool,
}; };
export default DynamicZoneLabel;

View File

@ -1,66 +1,54 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render as renderRTL } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import AddComponentButton from '../AddComponentButton'; import { AddComponentButton } from '../AddComponentButton';
describe('<AddComponentButton />', () => { describe('<AddComponentButton />', () => {
const setup = (props) => const render = (props) => ({
render( ...renderRTL(
<ThemeProvider theme={lightTheme}> <AddComponentButton onClick={jest.fn()} {...props}>
<IntlProvider locale="en" messages={{}} defaultLocale="en"> test
<AddComponentButton label="test" name="name" onClick={jest.fn()} {...props} /> </AddComponentButton>,
</IntlProvider> {
</ThemeProvider> wrapper: ({ children }) => (
); <ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
{children}
</IntlProvider>
</ThemeProvider>
),
}
),
user: userEvent.setup(),
});
it('should render the label by default', () => { it('should render the label by default', () => {
setup(); const { getByRole } = render();
expect(screen.getByText(/test/)).toBeInTheDocument(); expect(getByRole('button', { name: 'test' })).toBeInTheDocument();
}); });
it('should render the close label if the isOpen prop is true', () => { it('should call the onClick handler when the button is clicked', async () => {
setup({ isOpen: true });
expect(screen.getByText(/Close/)).toBeInTheDocument();
});
it('should render the name of the field when the label is an empty string', () => {
setup({ label: '' });
expect(screen.getByText(/name/)).toBeInTheDocument();
});
it('should render a too high error if there is hasMaxError is true and the component is not open', () => {
setup({ hasMaxError: true });
expect(screen.getByText(/The value is too high./)).toBeInTheDocument();
});
it('should render a label telling the user there are X missing components if hasMinError is true and the component is not open', () => {
setup({ hasMinError: true });
expect(screen.getByText(/missing components/)).toBeInTheDocument();
});
it('should call the onClick handler when the button is clicked', () => {
const onClick = jest.fn(); const onClick = jest.fn();
setup({ onClick }); const { getByRole, user } = render({ onClick });
screen.getByText(/test/).click(); await user.click(getByRole('button', { name: 'test' }));
expect(onClick).toHaveBeenCalled(); expect(onClick).toHaveBeenCalled();
}); });
it('should not call the onClick handler when the button is disabled', () => { it('should not call the onClick handler when the button is disabled', async () => {
const onClick = jest.fn(); const onClick = jest.fn();
setup({ onClick, isDisabled: true }); const { getByRole, user } = render({ onClick, isDisabled: true });
screen.getByText(/test/).click(); await expect(() => user.click(getByRole('button', { name: 'test' }))).rejects.toThrow(
/pointer-events: none/
);
expect(onClick).not.toHaveBeenCalled(); expect(onClick).not.toHaveBeenCalled();
}); });

View File

@ -1,25 +0,0 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import GlobalStyle from '../../../../../components/GlobalStyle';
import ComponentCard from '../ComponentCard';
describe('ComponentCard', () => {
const setup = (props) =>
render(
<ThemeProvider theme={lightTheme}>
<ComponentCard {...props}>test</ComponentCard>
<GlobalStyle />
</ThemeProvider>
);
it('should call the onClick handler when passed', () => {
const onClick = jest.fn();
const { getByText } = setup({ onClick });
fireEvent.click(getByText('test'));
expect(onClick).toHaveBeenCalled();
});
});

View File

@ -1,27 +1,35 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react'; import { render as renderRTL } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import ComponentCategory from '../ComponentCategory'; import { ComponentCategory } from '../ComponentCategory';
describe('ComponentCategory', () => { describe('ComponentCategory', () => {
const setup = (props) => const render = (props) => ({
render( ...renderRTL(
<ThemeProvider theme={lightTheme}> <ComponentCategory
<IntlProvider locale="en" messages={{}} defaultLocale="en"> onAddComponent={jest.fn()}
<ComponentCategory onToggle={jest.fn()}
onAddComponent={jest.fn()} category="testing"
onToggle={jest.fn()} {...props}
category="testing" />,
{...props} {
/> wrapper: ({ children }) => (
</IntlProvider> <ThemeProvider theme={lightTheme}>
</ThemeProvider> <IntlProvider locale="en" messages={{}} defaultLocale="en">
); {children}
</IntlProvider>
</ThemeProvider>
),
}
),
user: userEvent.setup(),
});
it('should render my array of components when passed and the accordion is open', () => { it('should render my array of components when passed and the accordion is open', () => {
setup({ const { getByRole } = render({
isOpen: true, isOpen: true,
components: [ components: [
{ {
@ -34,31 +42,31 @@ describe('ComponentCategory', () => {
], ],
}); });
expect(screen.getByText(/myComponent/)).toBeInTheDocument(); expect(getByRole('button', { name: /myComponent/ })).toBeInTheDocument();
}); });
it('should render the category as the accordion buttons label', () => { it('should render the category as the accordion buttons label', () => {
setup({ const { getByText } = render({
category: 'myCategory', category: 'myCategory',
}); });
expect(screen.getByText(/myCategory/)).toBeInTheDocument(); expect(getByText(/myCategory/)).toBeInTheDocument();
}); });
it('should call the onToggle callback when the accordion trigger is pressed', () => { it('should call the onToggle callback when the accordion trigger is pressed', async () => {
const onToggle = jest.fn(); const onToggle = jest.fn();
setup({ const { getByRole, user } = render({
onToggle, onToggle,
}); });
fireEvent.click(screen.getByText(/testing/)); await user.click(getByRole('button', { name: /testing/ }));
expect(onToggle).toHaveBeenCalledWith('testing'); expect(onToggle).toHaveBeenCalledWith('testing');
}); });
it('should call onAddComponent with the componentUid when a ComponentCard is clicked', () => { it('should call onAddComponent with the componentUid when a ComponentCard is clicked', async () => {
const onAddComponent = jest.fn(); const onAddComponent = jest.fn();
setup({ const { getByRole, user } = render({
isOpen: true, isOpen: true,
onAddComponent, onAddComponent,
components: [ components: [
@ -72,7 +80,7 @@ describe('ComponentCategory', () => {
], ],
}); });
fireEvent.click(screen.getByText(/myComponent/)); await user.click(getByRole('button', { name: /myComponent/ }));
expect(onAddComponent).toHaveBeenCalledWith('test'); expect(onAddComponent).toHaveBeenCalledWith('test');
}); });

View File

@ -1,70 +1,73 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react'; import { render as renderRTL } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import ComponentPicker from '../ComponentPicker'; import { ComponentPicker } from '../ComponentPicker';
import { layoutData } from './fixtures'; import { dynamicComponentsByCategory } from './fixtures';
jest.mock('../../../../hooks', () => ({
useContentTypeLayout: jest.fn().mockReturnValue({
getComponentLayout: jest.fn().mockImplementation((componentUid) => layoutData[componentUid]),
}),
}));
describe('ComponentPicker', () => { describe('ComponentPicker', () => {
afterEach(() => {
jest.restoreAllMocks();
});
const Component = (props) => ( const Component = (props) => (
<ThemeProvider theme={lightTheme}> <ComponentPicker
<IntlProvider locale="en" messages={{}} defaultLocale="en"> isOpen
<ComponentPicker isOpen onClickAddComponent={jest.fn()} {...props} /> onClickAddComponent={jest.fn()}
</IntlProvider> dynamicComponentsByCategory={dynamicComponentsByCategory}
</ThemeProvider> {...props}
/>
); );
const setup = (props) => render(<Component {...props} />); const render = (props) => ({
...renderRTL(<Component {...props} />, {
wrapper: ({ children }) => (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
{children}
</IntlProvider>
</ThemeProvider>
),
}),
user: userEvent.setup(),
});
it('should by default give me the instruction to Pick one Component', () => { it('should by default give me the instruction to Pick one Component', () => {
setup(); const { getByText } = render();
expect(screen.getByText(/Pick one component/)).toBeInTheDocument(); expect(getByText(/Pick one component/)).toBeInTheDocument();
}); });
it('should render null if isOpen is false', () => { it('should render null if isOpen is false', () => {
setup({ isOpen: false }); const { queryByText } = render({ isOpen: false });
expect(screen.queryByText(/Pick one component/)).not.toBeInTheDocument(); expect(queryByText(/Pick one component/)).not.toBeInTheDocument();
}); });
it('should render the category names by default', () => { it('should render the category names by default', () => {
setup({ components: ['component1', 'component2'] }); const { getByText } = render({ components: ['component1', 'component2'] });
expect(screen.getByText(/myComponents/)).toBeInTheDocument(); expect(getByText(/myComponents/)).toBeInTheDocument();
}); });
it('should open the first category of components when isOpen changes to true from false', () => { it('should open the first category of components when isOpen changes to true from false', () => {
const { rerender } = setup({ const { rerender, getByRole, queryByRole } = render({
isOpen: false, isOpen: false,
}); });
rerender(<Component isOpen components={['component1', 'component2', 'component3']} />); rerender(<Component isOpen components={['component1', 'component2', 'component3']} />);
expect(screen.getByText(/component1/)).toBeInTheDocument(); expect(getByRole('button', { name: /component1/ })).toBeInTheDocument();
expect(screen.queryByText(/component3/)).not.toBeInTheDocument(); expect(queryByRole('button', { name: /component3/ })).not.toBeInTheDocument();
}); });
it('should call onClickAddComponent with the componentUid when a Component is clicked', () => { it('should call onClickAddComponent with the componentUid when a Component is clicked', async () => {
const onClickAddComponent = jest.fn(); const onClickAddComponent = jest.fn();
setup({ const { user, getByRole } = render({
components: ['component1', 'component2'], components: ['component1', 'component2'],
onClickAddComponent, onClickAddComponent,
}); });
fireEvent.click(screen.getByText(/component1/)); await user.click(getByRole('button', { name: /component1/ }));
expect(onClickAddComponent).toHaveBeenCalledWith('component1'); expect(onClickAddComponent).toHaveBeenCalledWith('component1');
}); });

View File

@ -1,13 +1,14 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react'; import { render as renderRTL, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import DynamicComponent from '../DynamicComponent'; import { DynamicComponent } from '../DynamicComponent';
import { layoutData } from './fixtures'; import { layoutData, dynamicComponentsByCategory } from './fixtures';
jest.mock('../../../../hooks', () => ({ jest.mock('../../../../hooks', () => ({
...jest.requireActual('../../../../hooks'), ...jest.requireActual('../../../../hooks'),
@ -42,65 +43,72 @@ describe('DynamicComponent', () => {
// eslint-disable-next-line react/prop-types // eslint-disable-next-line react/prop-types
const TestComponent = ({ testingDnd, ...restProps }) => ( const TestComponent = ({ testingDnd, ...restProps }) => (
<ThemeProvider theme={lightTheme}> <>
<IntlProvider locale="en" messages={{}} defaultLocale="en"> <DynamicComponent {...defaultProps} {...restProps} />
<DndProvider backend={HTML5Backend}> {testingDnd ? <DynamicComponent {...defaultProps} {...restProps} /> : null}
<DynamicComponent {...defaultProps} {...restProps} /> </>
{testingDnd ? <DynamicComponent {...defaultProps} {...restProps} /> : null}
</DndProvider>
</IntlProvider>
</ThemeProvider>
); );
const setup = (props) => render(<TestComponent {...props} />); const render = (props) => ({
...renderRTL(<TestComponent {...props} />, {
it('should by default render the name of the component in the accordion trigger', () => { wrapper: ({ children }) => (
setup(); <ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
expect(screen.getByRole('button', { name: 'component1' })).toBeInTheDocument(); <DndProvider backend={HTML5Backend}>{children}</DndProvider>
</IntlProvider>
</ThemeProvider>
),
}),
user: userEvent.setup(),
}); });
it('should allow removal of the component & call the onRemoveComponentClick callback when the field isAllowed', () => { it('should by default render the name of the component in the accordion trigger', () => {
const onRemoveComponentClick = jest.fn(); const { getByRole } = render();
setup({ isFieldAllowed: true, onRemoveComponentClick });
fireEvent.click(screen.getByRole('button', { name: 'Delete component1' })); expect(getByRole('button', { name: 'component1' })).toBeInTheDocument();
});
it('should allow removal of the component & call the onRemoveComponentClick callback when the field isAllowed', async () => {
const onRemoveComponentClick = jest.fn();
const { getByRole, user } = render({ isFieldAllowed: true, onRemoveComponentClick });
await user.click(getByRole('button', { name: 'Delete component1' }));
expect(onRemoveComponentClick).toHaveBeenCalled(); expect(onRemoveComponentClick).toHaveBeenCalled();
}); });
it('should not show you the delete component button if isFieldAllowed is false', () => { it('should not show you the delete component button if isFieldAllowed is false', () => {
setup({ isFieldAllowed: false }); const { queryByRole } = render({ isFieldAllowed: false });
expect(screen.queryByRole('button', { name: 'Delete component1' })).not.toBeInTheDocument(); expect(queryByRole('button', { name: 'Delete component1' })).not.toBeInTheDocument();
}); });
it('should hide the field component when you close the accordion', () => { it('should hide the field component when you close the accordion', async () => {
setup(); const { queryByText, user, getByRole } = render();
expect(screen.queryByText("I'm a field component")).toBeInTheDocument(); expect(queryByText("I'm a field component")).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'component1' })); await user.click(getByRole('button', { name: 'component1' }));
expect(screen.queryByText("I'm a field component")).not.toBeInTheDocument(); expect(queryByText("I'm a field component")).not.toBeInTheDocument();
}); });
describe('Keyboard drag and drop', () => { describe('Keyboard drag and drop', () => {
it('should not move with arrow keys if the button is not pressed first', () => { it('should not move with arrow keys if the button is not pressed first', () => {
const onMoveComponent = jest.fn(); const onMoveComponent = jest.fn();
setup({ const { getAllByText } = render({
onMoveComponent, onMoveComponent,
testingDnd: true, testingDnd: true,
}); });
const [draggedItem] = screen.getAllByText('Drag'); const [draggedItem] = getAllByText('Drag');
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' }); fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
expect(onMoveComponent).not.toBeCalled(); expect(onMoveComponent).not.toBeCalled();
}); });
it('should move with the arrow keys if the button has been activated first', () => { it('should move with the arrow keys if the button has been activated first', () => {
const onMoveComponent = jest.fn(); const onMoveComponent = jest.fn();
setup({ onMoveComponent, testingDnd: true }); const { getAllByText } = render({ onMoveComponent, testingDnd: true });
const [draggedItem] = screen.getAllByText('Drag'); const [draggedItem] = getAllByText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' }); fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
expect(onMoveComponent).toBeCalledWith(1, 0); expect(onMoveComponent).toBeCalledWith(1, 0);
@ -108,8 +116,8 @@ describe('DynamicComponent', () => {
it('should move with the arrow keys if the button has been activated and then not move after the button has been deactivated', () => { it('should move with the arrow keys if the button has been activated and then not move after the button has been deactivated', () => {
const onMoveComponent = jest.fn(); const onMoveComponent = jest.fn();
setup({ onMoveComponent, testingDnd: true }); const { getAllByText } = render({ onMoveComponent, testingDnd: true });
const [draggedItem] = screen.getAllByText('Drag'); const [draggedItem] = getAllByText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' }); fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
@ -119,8 +127,8 @@ describe('DynamicComponent', () => {
it('should exit drag and drop mode when the escape key is pressed', () => { it('should exit drag and drop mode when the escape key is pressed', () => {
const onMoveComponent = jest.fn(); const onMoveComponent = jest.fn();
setup({ onMoveComponent, testingDnd: true }); const { getAllByText } = render({ onMoveComponent, testingDnd: true });
const [draggedItem] = screen.getAllByText('Drag'); const [draggedItem] = getAllByText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'Escape', code: 'Escape' }); fireEvent.keyDown(draggedItem, { key: 'Escape', code: 'Escape' });
fireEvent.keyDown(draggedItem, { key: 'ArrowUp', code: 'ArrowUp' }); fireEvent.keyDown(draggedItem, { key: 'ArrowUp', code: 'ArrowUp' });
@ -128,5 +136,66 @@ describe('DynamicComponent', () => {
}); });
}); });
describe('adding above and below components', () => {
it('should render a menu button with two items that have submenus that list the components grouped by categories', async () => {
const { getByRole, getByText, user } = render({ dynamicComponentsByCategory });
expect(getByRole('button', { name: 'More actions' })).toBeInTheDocument();
await user.click(getByRole('button', { name: 'More actions' }));
expect(getByRole('menuitem', { name: 'Add component above' })).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'Add component below' })).toBeInTheDocument();
await user.click(getByRole('menuitem', { name: 'Add component above' }));
expect(getByText('myComponents')).toBeInTheDocument();
expect(getByText('otherComponents')).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'component1' })).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'component2' })).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'component3' })).toBeInTheDocument();
await user.click(getByRole('menuitem', { name: 'Add component below' }));
expect(getByText('myComponents')).toBeInTheDocument();
expect(getByText('otherComponents')).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'component1' })).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'component2' })).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'component3' })).toBeInTheDocument();
});
it('should call the onAddComponent callback with the correct index when adding above', async () => {
const onAddComponent = jest.fn();
const { getByRole, user } = render({ dynamicComponentsByCategory, onAddComponent, index: 0 });
await user.click(getByRole('button', { name: 'More actions' }));
await user.click(getByRole('menuitem', { name: 'Add component above' }));
/**
* @note for some reason, user.click() doesn't work here
*/
fireEvent.click(getByRole('menuitem', { name: 'component1' }));
expect(onAddComponent).toHaveBeenCalledWith('component1', 0);
});
it('should call the onAddComponent callback with the correct index when adding below', async () => {
const onAddComponent = jest.fn();
const { getByRole, user } = render({ dynamicComponentsByCategory, onAddComponent, index: 0 });
await user.click(getByRole('button', { name: 'More actions' }));
await user.click(getByRole('menuitem', { name: 'Add component below' }));
/**
* @note for some reason, user.click() doesn't work here
*/
fireEvent.click(getByRole('menuitem', { name: 'component1' }));
expect(onAddComponent).toHaveBeenCalledWith('component1', 1);
});
});
it.todo('should handle errors in the fields'); it.todo('should handle errors in the fields');
}); });

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { render, screen } from '@testing-library/react'; import { render as renderRTL } from '@testing-library/react';
import { ThemeProvider, lightTheme, Tooltip } from '@strapi/design-system'; import { ThemeProvider, lightTheme, Tooltip } from '@strapi/design-system';
import { Earth } from '@strapi/icons'; import { Earth } from '@strapi/icons';
import DynamicZoneLabel from '../DynamicZoneLabel'; import { DynamicZoneLabel } from '../DynamicZoneLabel';
const LabelAction = () => { const LabelAction = () => {
return ( return (
@ -26,50 +26,50 @@ describe('DynamicZoneLabel', () => {
</ThemeProvider> </ThemeProvider>
); );
const setup = (props) => render(<Component {...props} />); const render = (props) => renderRTL(<Component {...props} />);
it('should render the label by default', () => { it('should render the label by default', () => {
setup(); const { getByText } = render();
expect(screen.getByText(/dynamic zone/)).toBeInTheDocument(); expect(getByText(/dynamic zone/)).toBeInTheDocument();
}); });
it('should render the name of the zone when there is no label', () => { it('should render the name of the zone when there is no label', () => {
setup({ label: '' }); const { getByText } = render({ label: '' });
expect(screen.getByText(/test/)).toBeInTheDocument(); expect(getByText(/test/)).toBeInTheDocument();
}); });
it('should always render the amount of components no matter the value', () => { it('should always render the amount of components no matter the value', () => {
const { rerender } = setup({ numberOfComponents: 0 }); const { rerender, getByText } = render({ numberOfComponents: 0 });
expect(screen.getByText(/0/)).toBeInTheDocument(); expect(getByText(/0/)).toBeInTheDocument();
rerender(<Component numberOfComponents={2} />); rerender(<Component numberOfComponents={2} />);
expect(screen.getByText(/2/)).toBeInTheDocument(); expect(getByText(/2/)).toBeInTheDocument();
}); });
it('should render an asteriks when the required prop is true', () => { it('should render an asteriks when the required prop is true', () => {
setup({ required: true }); const { getByText } = render({ required: true });
expect(screen.getByText(/\*/)).toBeInTheDocument(); expect(getByText(/\*/)).toBeInTheDocument();
}); });
it('should render the labelAction when it is provided', () => { it('should render the labelAction when it is provided', () => {
setup({ labelAction: <LabelAction /> }); const { getByLabelText } = render({ labelAction: <LabelAction /> });
expect(screen.getByLabelText(/i18n/)).toBeInTheDocument(); expect(getByLabelText(/i18n/)).toBeInTheDocument();
}); });
it('should render a description if passed as a prop', () => { it('should render a description if passed as a prop', () => {
setup({ const { getByText } = render({
intlDescription: { intlDescription: {
id: 'description', id: 'description',
defaultMessage: 'description', defaultMessage: 'description',
}, },
}); });
expect(screen.getByText(/description/)).toBeInTheDocument(); expect(getByText(/description/)).toBeInTheDocument();
}); });
}); });

View File

@ -21,3 +21,31 @@ export const layoutData = {
}, },
}, },
}; };
export const dynamicComponentsByCategory = {
myComponents: [
{
componentUid: 'component1',
info: {
displayName: 'component1',
icon: undefined,
},
},
{
componentUid: 'component2',
info: {
displayName: 'component2',
icon: undefined,
},
},
],
otherComponents: [
{
componentUid: 'component3',
info: {
displayName: 'component3',
icon: undefined,
},
},
],
};

View File

@ -1,76 +1,113 @@
import React, { memo, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Box, Flex, VisuallyHidden } from '@strapi/design-system'; import { Box, Flex, VisuallyHidden } from '@strapi/design-system';
import { NotAllowedInput, useNotification } from '@strapi/helper-plugin'; import { NotAllowedInput, useNotification, useCMEditViewDataManager } from '@strapi/helper-plugin';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { getTrad } from '../../utils'; import { getTrad } from '../../utils';
import connect from './utils/connect'; import { DynamicComponent } from './components/DynamicComponent';
import select from './utils/select'; import { AddComponentButton } from './components/AddComponentButton';
import { DynamicZoneLabel } from './components/DynamicZoneLabel';
import DynamicZoneComponent from './components/DynamicComponent'; import { ComponentPicker } from './components/ComponentPicker';
import AddComponentButton from './components/AddComponentButton';
import DynamicZoneLabel from './components/DynamicZoneLabel';
import ComponentPicker from './components/ComponentPicker';
import { useContentTypeLayout } from '../../hooks'; import { useContentTypeLayout } from '../../hooks';
const DynamicZone = ({ const DynamicZone = ({ name, labelAction, fieldSchema, metadatas }) => {
name, // We cannot use the default props here
// Passed with the select function const { max = Infinity, min = -Infinity, components = [], required = false } = fieldSchema;
addComponentToDynamicZone,
formErrors,
isCreatingEntry,
isFieldAllowed,
isFieldReadable,
labelAction,
moveComponentField,
removeComponentFromDynamicZone,
dynamicDisplayedComponents,
fieldSchema,
metadatas,
}) => {
const [addComponentIsOpen, setAddComponentIsOpen] = useState(false); const [addComponentIsOpen, setAddComponentIsOpen] = useState(false);
const [liveText, setLiveText] = useState(''); const [liveText, setLiveText] = useState('');
const {
addComponentToDynamicZone,
createActionAllowedFields,
isCreatingEntry,
formErrors,
modifiedData,
moveComponentField,
removeComponentFromDynamicZone,
readActionAllowedFields,
updateActionAllowedFields,
} = useCMEditViewDataManager();
const dynamicDisplayedComponents = useMemo(
() =>
(modifiedData?.[name] ?? []).map((data) => {
return {
componentUid: data.__component,
id: data.id ?? data.__temp_key__,
};
}),
[modifiedData, name]
);
const { getComponentLayout } = useContentTypeLayout();
/**
* @type {Record<string, Array<{category: string; info: unknown, attributes: Record<string, unknown>}>>}
*/
const dynamicComponentsByCategory = useMemo(() => {
return components.reduce((acc, componentUid) => {
const { category, info, attributes } = getComponentLayout(componentUid);
const component = { componentUid, info, attributes };
if (!acc[category]) {
acc[category] = [];
}
acc[category] = [...acc[category], component];
return acc;
}, {});
}, [components, getComponentLayout]);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const { getComponentLayout, components } = useContentTypeLayout();
const isFieldAllowed = useMemo(() => {
const allowedFields = isCreatingEntry ? createActionAllowedFields : updateActionAllowedFields;
return allowedFields.includes(name);
}, [name, isCreatingEntry, createActionAllowedFields, updateActionAllowedFields]);
const isFieldReadable = useMemo(() => {
const allowedFields = isCreatingEntry ? [] : readActionAllowedFields;
return allowedFields.includes(name);
}, [name, isCreatingEntry, readActionAllowedFields]);
const dynamicDisplayedComponentsLength = dynamicDisplayedComponents.length; const dynamicDisplayedComponentsLength = dynamicDisplayedComponents.length;
const intlDescription = metadatas.description const intlDescription = metadatas.description
? { id: metadatas.description, defaultMessage: metadatas.description } ? { id: metadatas.description, defaultMessage: metadatas.description }
: null; : null;
// We cannot use the default props here const dynamicZoneError = formErrors[name];
const { max = Infinity, min = -Infinity } = fieldSchema;
const dynamicZoneErrors = useMemo(() => {
return Object.keys(formErrors)
.filter((key) => {
return key === name;
})
.map((key) => formErrors[key]);
}, [formErrors, name]);
const missingComponentNumber = min - dynamicDisplayedComponentsLength; const missingComponentNumber = min - dynamicDisplayedComponentsLength;
const hasError = dynamicZoneErrors.length > 0; const hasError = !!dynamicZoneError;
const hasMinError = const handleAddComponent = (componentUid, position) => {
dynamicZoneErrors.length > 0 && get(dynamicZoneErrors, [0, 'id'], '').includes('min');
const hasMaxError =
hasError && get(dynamicZoneErrors, [0, 'id'], '') === 'components.Input.error.validation.max';
const handleAddComponent = (componentUid) => {
setAddComponentIsOpen(false); setAddComponentIsOpen(false);
const componentLayoutData = getComponentLayout(componentUid); const componentLayoutData = getComponentLayout(componentUid);
addComponentToDynamicZone(name, componentLayoutData, components, hasError); const allComponents = Object.values(dynamicComponentsByCategory).reduce((acc, components) => {
const componentObjects = components.reduce((acc, { componentUid, attributes }) => {
acc[componentUid] = {
attributes,
uid: componentUid,
};
return acc;
}, {});
return { ...acc, ...componentObjects };
}, {});
addComponentToDynamicZone(name, componentLayoutData, allComponents, hasError, position);
}; };
const handleClickOpenPicker = () => { const handleClickOpenPicker = () => {
@ -160,6 +197,38 @@ const DynamicZone = ({
removeComponentFromDynamicZone(name, currentIndex); removeComponentFromDynamicZone(name, currentIndex);
}; };
const renderButtonLabel = () => {
if (addComponentIsOpen) {
return formatMessage({ id: 'app.utils.close-label', defaultMessage: 'Close' });
}
if (hasError && dynamicZoneError.id.includes('max')) {
return formatMessage({
id: 'components.Input.error.validation.max',
defaultMessage: 'The value is too high.',
});
}
if (hasError && dynamicZoneError.id.includes('min')) {
return formatMessage(
{
id: getTrad(`components.DynamicZone.missing-components`),
defaultMessage:
'There {number, plural, =0 {are # missing components} one {is # missing component} other {are # missing components}}',
},
{ number: missingComponentNumber }
);
}
return formatMessage(
{
id: getTrad('components.DynamicZone.add-component'),
defaultMessage: 'Add a component to {componentName}',
},
{ componentName: metadatas.label || name }
);
};
if (!isFieldAllowed && (isCreatingEntry || (!isFieldReadable && !isCreatingEntry))) { if (!isFieldAllowed && (isCreatingEntry || (!isFieldReadable && !isCreatingEntry))) {
return ( return (
<NotAllowedInput <NotAllowedInput
@ -183,7 +252,7 @@ const DynamicZone = ({
labelAction={labelAction} labelAction={labelAction}
name={name} name={name}
numberOfComponents={dynamicDisplayedComponentsLength} numberOfComponents={dynamicDisplayedComponentsLength}
required={fieldSchema.required || false} required={required}
/> />
<VisuallyHidden id={ariaDescriptionId}> <VisuallyHidden id={ariaDescriptionId}>
{formatMessage({ {formatMessage({
@ -194,7 +263,7 @@ const DynamicZone = ({
<VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden> <VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>
<ol aria-describedby={ariaDescriptionId}> <ol aria-describedby={ariaDescriptionId}>
{dynamicDisplayedComponents.map(({ componentUid, id }, index) => ( {dynamicDisplayedComponents.map(({ componentUid, id }, index) => (
<DynamicZoneComponent <DynamicComponent
componentUid={componentUid} componentUid={componentUid}
formErrors={formErrors} formErrors={formErrors}
key={`${componentUid}-${id}`} key={`${componentUid}-${id}`}
@ -206,26 +275,26 @@ const DynamicZone = ({
onCancel={handleCancel} onCancel={handleCancel}
onDropItem={handleDropItem} onDropItem={handleDropItem}
onGrabItem={handleGrabItem} onGrabItem={handleGrabItem}
onAddComponent={handleAddComponent}
dynamicComponentsByCategory={dynamicComponentsByCategory}
/> />
))} ))}
</ol> </ol>
</Box> </Box>
)} )}
<Flex justifyContent="center">
<AddComponentButton <AddComponentButton
hasError={hasError} hasError={hasError}
hasMaxError={hasMaxError} isDisabled={!isFieldAllowed}
hasMinError={hasMinError} isOpen={addComponentIsOpen}
isDisabled={!isFieldAllowed} onClick={handleClickOpenPicker}
label={metadatas.label} >
missingComponentNumber={missingComponentNumber} {renderButtonLabel()}
isOpen={addComponentIsOpen} </AddComponentButton>
name={name} </Flex>
onClick={handleClickOpenPicker}
/>
<ComponentPicker <ComponentPicker
dynamicComponentsByCategory={dynamicComponentsByCategory}
isOpen={addComponentIsOpen} isOpen={addComponentIsOpen}
components={fieldSchema.components ?? []}
onClickAddComponent={handleAddComponent} onClickAddComponent={handleAddComponent}
/> />
</Flex> </Flex>
@ -233,44 +302,23 @@ const DynamicZone = ({
}; };
DynamicZone.defaultProps = { DynamicZone.defaultProps = {
dynamicDisplayedComponents: [], fieldSchema: {},
fieldSchema: {
max: Infinity,
min: -Infinity,
},
labelAction: null, labelAction: null,
}; };
DynamicZone.propTypes = { DynamicZone.propTypes = {
addComponentToDynamicZone: PropTypes.func.isRequired,
dynamicDisplayedComponents: PropTypes.arrayOf(
PropTypes.shape({
componentUid: PropTypes.string.isRequired,
id: PropTypes.number.isRequired,
})
),
fieldSchema: PropTypes.shape({ fieldSchema: PropTypes.shape({
components: PropTypes.array.isRequired, components: PropTypes.array,
max: PropTypes.number, max: PropTypes.number,
min: PropTypes.number, min: PropTypes.number,
required: PropTypes.bool, required: PropTypes.bool,
}), }),
formErrors: PropTypes.object.isRequired,
isCreatingEntry: PropTypes.bool.isRequired,
isFieldAllowed: PropTypes.bool.isRequired,
isFieldReadable: PropTypes.bool.isRequired,
labelAction: PropTypes.element, labelAction: PropTypes.element,
metadatas: PropTypes.shape({ metadatas: PropTypes.shape({
description: PropTypes.string, description: PropTypes.string,
label: PropTypes.string, label: PropTypes.string,
}).isRequired, }).isRequired,
moveComponentField: PropTypes.func.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
removeComponentFromDynamicZone: PropTypes.func.isRequired,
}; };
const Memoized = memo(DynamicZone, isEqual);
export default connect(Memoized, select);
export { DynamicZone }; export { DynamicZone };

View File

@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react'; import { render as renderRTL } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
@ -11,9 +13,25 @@ import { layoutData } from './fixtures';
const toggleNotification = jest.fn(); const toggleNotification = jest.fn();
const TEST_NAME = 'DynamicZoneComponent';
const defaultCMEditViewMock = {
isCreatingEntry: false,
addComponentToDynamicZone: jest.fn(),
removeComponentFromDynamicZone: jest.fn(),
moveComponentField: jest.fn(),
createActionAllowedFields: [TEST_NAME],
updateActionAllowedFields: [TEST_NAME],
readActionAllowedFields: [TEST_NAME],
modifiedData: {},
formErrors: {},
};
jest.mock('@strapi/helper-plugin', () => ({ jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'), ...jest.requireActual('@strapi/helper-plugin'),
useCMEditViewDataManager: jest.fn().mockImplementation(() => ({ modifiedData: {} })), useCMEditViewDataManager: jest.fn().mockImplementation(() => ({
...defaultCMEditViewMock,
})),
useNotification: jest.fn().mockImplementation(() => toggleNotification), useNotification: jest.fn().mockImplementation(() => toggleNotification),
NotAllowedInput: () => 'This field is not allowed', NotAllowedInput: () => 'This field is not allowed',
})); }));
@ -38,154 +56,193 @@ describe('DynamicZone', () => {
}); });
const defaultProps = { const defaultProps = {
addComponentToDynamicZone: jest.fn(),
isCreatingEntry: true,
isFieldAllowed: true,
isFieldReadable: true,
fieldSchema: { fieldSchema: {
components: ['component1', 'component2', 'component3'], components: ['component1', 'component2', 'component3'],
}, },
formErrors: {},
metadatas: { metadatas: {
label: 'dynamic zone', label: 'dynamic zone',
description: 'dynamic description', description: 'dynamic description',
}, },
moveComponentField: jest.fn(),
name: 'DynamicZoneComponent', name: 'DynamicZoneComponent',
removeComponentFromDynamicZone: jest.fn(),
}; };
const TestComponent = (props) => ( const TestComponent = (props) => <DynamicZone {...defaultProps} {...props} />;
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<DndProvider backend={HTML5Backend}>
<DynamicZone {...defaultProps} {...props} />
</DndProvider>
</IntlProvider>
</ThemeProvider>
);
const setup = (props) => render(<TestComponent {...props} />); const render = (props) => ({
...renderRTL(<TestComponent {...props} />, {
wrapper: ({ children }) => (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<DndProvider backend={HTML5Backend}>{children}</DndProvider>
</IntlProvider>
</ThemeProvider>
),
}),
user: userEvent.setup(),
});
describe('rendering', () => { describe('rendering', () => {
it('should not render the dynamic zone if there are no dynamic components to render', () => { it('should not render the dynamic zone if there are no dynamic components to render', () => {
setup(); const { queryByText } = render();
expect(screen.queryByText('dynamic zone')).not.toBeInTheDocument(); expect(queryByText('dynamic zone')).not.toBeInTheDocument();
expect(screen.queryByText('dynamic description')).not.toBeInTheDocument(); expect(queryByText('dynamic description')).not.toBeInTheDocument();
}); });
it('should render the AddComponentButton by default and render the ComponentPicker when that button is clicked', () => { it('should render the AddComponentButton by default and render the ComponentPicker when that button is clicked', async () => {
setup(); const { getByRole, getByText, user } = render();
const addComponentButton = screen.getByRole('button', { name: /Add a component to/i }); const addComponentButton = getByRole('button', { name: /Add a component to/i });
expect(addComponentButton).toBeInTheDocument(); expect(addComponentButton).toBeInTheDocument();
fireEvent.click(addComponentButton); await user.click(addComponentButton);
expect(screen.getByText('Pick one component')).toBeInTheDocument(); expect(getByText('Pick one component')).toBeInTheDocument();
}); });
it('should render the dynamic zone of components when there are dynamic components to render', () => { it('should render the dynamic zone of components when there are dynamic components to render', () => {
setup({ useCMEditViewDataManager.mockImplementationOnce(() => ({
dynamicDisplayedComponents: [ ...defaultCMEditViewMock,
{ componentUid: 'component1', id: 0 }, modifiedData: {
{ componentUid: 'component2', id: 0 }, [TEST_NAME]: [
], {
}); __component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
],
},
}));
const { getByText } = render();
expect(screen.getByText('dynamic zone')).toBeInTheDocument(); expect(getByText('dynamic zone')).toBeInTheDocument();
expect(screen.getByText('dynamic description')).toBeInTheDocument(); expect(getByText('dynamic description')).toBeInTheDocument();
expect(screen.getByText('component1')).toBeInTheDocument(); expect(getByText('component1')).toBeInTheDocument();
expect(screen.getByText('component2')).toBeInTheDocument(); expect(getByText('component2')).toBeInTheDocument();
}); });
it('should render the not allowed input if the field is not allowed & the entry is being created', () => { it('should render the not allowed input if the field is not allowed & the entry is being created', () => {
setup({ useCMEditViewDataManager.mockImplementationOnce(() => ({
isFieldAllowed: false, ...defaultCMEditViewMock,
isCreatingEntry: true, isCreatingEntry: true,
}); createActionAllowedFields: [],
}));
const { queryByText, getByText } = render();
expect(screen.queryByText('dynamic zone')).not.toBeInTheDocument(); expect(queryByText('dynamic zone')).not.toBeInTheDocument();
expect(screen.getByText('This field is not allowed')).toBeInTheDocument(); expect(getByText('This field is not allowed')).toBeInTheDocument();
}); });
it('should render the not allowed input if the field is not allowed & the entry is not being created and the field is not readable', () => { it('should render the not allowed input if the field is not allowed & the entry is not being created and the field is not readable', () => {
setup({ useCMEditViewDataManager.mockImplementationOnce(() => ({
isFieldAllowed: false, ...defaultCMEditViewMock,
isCreatingEntry: false, updateActionAllowedFields: [],
isFieldReadable: false, readActionAllowedFields: [],
}); }));
const { queryByText, getByText } = render();
expect(screen.queryByText('dynamic zone')).not.toBeInTheDocument(); expect(queryByText('dynamic zone')).not.toBeInTheDocument();
expect(screen.getByText('This field is not allowed')).toBeInTheDocument(); expect(getByText('This field is not allowed')).toBeInTheDocument();
}); });
}); });
describe('callbacks', () => { describe('callbacks', () => {
it('should call the addComponentToDynamicZone callback when the AddComponentButton is clicked', () => { it('should call the addComponentToDynamicZone callback when the AddComponentButton is clicked', async () => {
const addComponentToDynamicZone = jest.fn(); const addComponentToDynamicZone = jest.fn();
useCMEditViewDataManager.mockImplementation(() => ({
...defaultCMEditViewMock,
addComponentToDynamicZone,
}));
setup({ addComponentToDynamicZone }); const { user, getByRole } = render();
const addComponentButton = screen.getByRole('button', { name: /Add a component to/i }); const addComponentButton = getByRole('button', { name: /Add a component to/i });
fireEvent.click(addComponentButton); await user.click(addComponentButton);
const componentPickerButton = screen.getByRole('button', { const componentPickerButton = getByRole('button', {
name: /component1/i, name: 'component1',
}); });
fireEvent.click(componentPickerButton); await user.click(componentPickerButton);
expect(addComponentToDynamicZone).toHaveBeenCalledWith( expect(addComponentToDynamicZone).toHaveBeenCalledWith(
'DynamicZoneComponent', 'DynamicZoneComponent',
{ category: 'myComponents', info: { displayName: 'component1', icon: undefined } }, { category: 'myComponents', info: { displayName: 'component1', icon: undefined } },
undefined, expect.any(Object),
false false,
undefined
); );
}); });
it('should call the removeComponentFromDynamicZone callback when the RemoveButton is clicked', () => { it('should call the removeComponentFromDynamicZone callback when the RemoveButton is clicked', async () => {
const removeComponentFromDynamicZone = jest.fn(); const removeComponentFromDynamicZone = jest.fn();
useCMEditViewDataManager.mockImplementationOnce(() => ({
setup({ ...defaultCMEditViewMock,
removeComponentFromDynamicZone, removeComponentFromDynamicZone,
dynamicDisplayedComponents: [ modifiedData: {
{ componentUid: 'component1', id: 0 }, [TEST_NAME]: [
{ componentUid: 'component2', id: 0 }, {
], __component: 'component1',
}); id: 0,
},
{
__component: 'component2',
id: 0,
},
],
},
}));
const removeButton = screen.getByRole('button', { name: /Delete component1/i }); const { user, getByRole } = render();
fireEvent.click(removeButton); const removeButton = getByRole('button', { name: /Delete component1/i });
await user.click(removeButton);
expect(removeComponentFromDynamicZone).toHaveBeenCalledWith('DynamicZoneComponent', 0); expect(removeComponentFromDynamicZone).toHaveBeenCalledWith('DynamicZoneComponent', 0);
}); });
}); });
describe('side effects', () => { describe('side effects', () => {
it('should call the toggleNotification callback if the amount of dynamic components has hit its max and the user tries to add another', () => { it('should call the toggleNotification callback if the amount of dynamic components has hit its max and the user tries to add another', async () => {
setup({ useCMEditViewDataManager.mockImplementationOnce(() => ({
dynamicDisplayedComponents: [ ...defaultCMEditViewMock,
{ componentUid: 'component1', id: 0 }, modifiedData: {
{ componentUid: 'component2', id: 0 }, [TEST_NAME]: [
{ componentUid: 'component3', id: 0 }, {
], __component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
{
__component: 'component3',
id: 0,
},
],
},
}));
const { user, getByRole } = render({
fieldSchema: { fieldSchema: {
components: ['component1', 'component2', 'component3'], components: ['component1', 'component2', 'component3'],
max: 3, max: 3,
}, },
}); });
const addComponentButton = screen.getByRole('button', { name: /Add a component to/i }); const addComponentButton = getByRole('button', { name: /Add a component to/i });
fireEvent.click(addComponentButton); await user.click(addComponentButton);
expect(toggleNotification).toHaveBeenCalledWith({ expect(toggleNotification).toHaveBeenCalledWith({
type: 'info', type: 'info',
@ -198,82 +255,188 @@ describe('DynamicZone', () => {
describe('Accessibility', () => { describe('Accessibility', () => {
it('should have have description text', () => { it('should have have description text', () => {
setup({ useCMEditViewDataManager.mockImplementationOnce(() => ({
dynamicDisplayedComponents: [ ...defaultCMEditViewMock,
{ componentUid: 'component1', id: 0 }, modifiedData: {
{ componentUid: 'component2', id: 0 }, [TEST_NAME]: [
], {
}); __component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
],
},
}));
expect(screen.queryByText('Press spacebar to grab and re-order')).toBeInTheDocument(); const { queryByText } = render();
expect(queryByText('Press spacebar to grab and re-order')).toBeInTheDocument();
}); });
it('should update the live text when an item has been grabbed', async () => { it('should update the live text when an item has been grabbed', async () => {
setup({ useCMEditViewDataManager.mockImplementation(() => ({
dynamicDisplayedComponents: [ ...defaultCMEditViewMock,
{ componentUid: 'component1', id: 0 }, modifiedData: {
{ componentUid: 'component2', id: 0 }, [TEST_NAME]: [
], {
}); __component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
],
},
}));
const [draggedItem] = screen.getAllByText('Drag'); const { getAllByRole, queryByText, user } = render();
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); const [draggedItem] = getAllByRole('button', { name: 'Drag' });
draggedItem.focus();
await user.keyboard('[Space]');
expect( expect(
screen.queryByText( queryByText(
/Press up and down arrow to change position, Spacebar to drop, Escape to cancel/ /Press up and down arrow to change position, Spacebar to drop, Escape to cancel/
) )
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('should change the live text when an item has been moved', () => { it('should change the live text when an item has been moved', async () => {
setup({ useCMEditViewDataManager.mockImplementation(() => ({
dynamicDisplayedComponents: [ ...defaultCMEditViewMock,
{ componentUid: 'component1', id: 0 }, modifiedData: {
{ componentUid: 'component2', id: 0 }, [TEST_NAME]: [
], {
}); __component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
],
},
}));
const [draggedItem] = screen.getAllByText('Drag'); const { user, getAllByRole, queryByText } = render();
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); const [draggedItem] = getAllByRole('button', { name: 'Drag' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
expect(screen.queryByText(/New position in list/)).toBeInTheDocument(); draggedItem.focus();
await user.keyboard('[Space]');
await user.keyboard('[ArrowDown]');
expect(queryByText(/New position in list/)).toBeInTheDocument();
}); });
it('should change the live text when an item has been dropped', () => { it('should change the live text when an item has been dropped', async () => {
setup({ useCMEditViewDataManager.mockImplementation(() => ({
dynamicDisplayedComponents: [ ...defaultCMEditViewMock,
{ componentUid: 'component1', id: 0 }, modifiedData: {
{ componentUid: 'component2', id: 0 }, [TEST_NAME]: [
], {
}); __component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
],
},
}));
const [draggedItem] = screen.getAllByText('Drag'); const { getAllByRole, user, queryByText } = render();
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); const [draggedItem] = getAllByRole('button', { name: 'Drag' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
expect(screen.queryByText(/Final position in list/)).toBeInTheDocument(); draggedItem.focus();
await user.keyboard('[Space]');
await user.keyboard('[ArrowDown]');
await user.keyboard('[Space]');
expect(queryByText(/Final position in list/)).toBeInTheDocument();
}); });
it('should change the live text after the reordering interaction has been cancelled', () => { it('should change the live text after the reordering interaction has been cancelled', async () => {
setup({ useCMEditViewDataManager.mockImplementation(() => ({
dynamicDisplayedComponents: [ ...defaultCMEditViewMock,
{ componentUid: 'component1', id: 0 }, modifiedData: {
{ componentUid: 'component2', id: 0 }, [TEST_NAME]: [
], {
}); __component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
],
},
}));
const [draggedItem] = screen.getAllByText('Drag'); const { getAllByRole, user, queryByText } = render();
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); const [draggedItem] = getAllByRole('button', { name: 'Drag' });
fireEvent.keyDown(draggedItem, { key: 'Escape', code: 'Escape' });
expect(screen.queryByText(/Re-order cancelled/)).toBeInTheDocument(); draggedItem.focus();
await user.keyboard('[Space]');
await user.keyboard('[Escape]');
expect(queryByText(/Re-order cancelled/)).toBeInTheDocument();
});
});
describe('Add component button', () => {
it('should render the close label if the component picker is open prop is true', async () => {
const { getByRole, user } = render();
expect(getByRole('button', { name: /Add a component to/i })).toBeInTheDocument();
await user.click(getByRole('button', { name: /Add a component to/i }));
expect(getByRole('button', { name: /Close/ })).toBeInTheDocument();
});
it('should render the name of the field when the label is an empty string', () => {
const { getByRole } = render({ metadatas: {} });
expect(getByRole('button', { name: `Add a component to ${TEST_NAME}` })).toBeInTheDocument();
});
it('should render a too high error if there is hasMaxError is true and the component is not open', () => {
useCMEditViewDataManager.mockImplementation(() => ({
...defaultCMEditViewMock,
formErrors: {
[TEST_NAME]: {
id: 'components.Input.error.validation.max',
},
},
}));
const { getByRole } = render();
expect(getByRole('button', { name: /The value is too high./ })).toBeInTheDocument();
});
it('should render a label telling the user there are X missing components if hasMinError is true and the component is not open', () => {
useCMEditViewDataManager.mockImplementation(() => ({
...defaultCMEditViewMock,
formErrors: {
[TEST_NAME]: {
id: 'components.Input.error.validation.min',
},
},
}));
const { getByRole } = render();
expect(getByRole('button', { name: /missing components/ })).toBeInTheDocument();
}); });
}); });
}); });

View File

@ -1,12 +0,0 @@
import React from 'react';
function connect(WrappedComponent, select) {
return (props) => {
// eslint-disable-next-line react/prop-types
const selectors = select(props.name);
return <WrappedComponent {...props} {...selectors} />;
};
}
export default connect;

View File

@ -1,53 +0,0 @@
import { useMemo } from 'react';
import get from 'lodash/get';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
function useSelect(name) {
const {
addComponentToDynamicZone,
createActionAllowedFields,
isCreatingEntry,
formErrors,
modifiedData,
moveComponentField,
removeComponentFromDynamicZone,
readActionAllowedFields,
updateActionAllowedFields,
} = useCMEditViewDataManager();
const dynamicDisplayedComponents = useMemo(
() =>
get(modifiedData, [name], []).map((data) => {
return {
componentUid: data.__component,
id: data.id ?? data.__temp_key__,
};
}),
[modifiedData, name]
);
const isFieldAllowed = useMemo(() => {
const allowedFields = isCreatingEntry ? createActionAllowedFields : updateActionAllowedFields;
return allowedFields.includes(name);
}, [name, isCreatingEntry, createActionAllowedFields, updateActionAllowedFields]);
const isFieldReadable = useMemo(() => {
const allowedFields = isCreatingEntry ? [] : readActionAllowedFields;
return allowedFields.includes(name);
}, [name, isCreatingEntry, readActionAllowedFields]);
return {
addComponentToDynamicZone,
formErrors,
isCreatingEntry,
isFieldAllowed,
isFieldReadable,
moveComponentField,
removeComponentFromDynamicZone,
dynamicDisplayedComponents,
};
}
export default useSelect;

View File

@ -195,14 +195,21 @@ const EditViewDataManagerProvider = ({
const dispatchAddComponent = useCallback( const dispatchAddComponent = useCallback(
(type) => (type) =>
(keys, componentLayoutData, components, shouldCheckErrors = false) => { (
keys,
componentLayoutData,
allComponents,
shouldCheckErrors = false,
position = undefined
) => {
trackUsageRef.current('didAddComponentToDynamicZone'); trackUsageRef.current('didAddComponentToDynamicZone');
dispatch({ dispatch({
type, type,
keys: keys.split('.'), keys: keys.split('.'),
position,
componentLayoutData, componentLayoutData,
allComponents: components, allComponents,
shouldCheckErrors, shouldCheckErrors,
}); });
}, },

View File

@ -52,7 +52,13 @@ const reducer = (state, action) =>
} }
case 'ADD_COMPONENT_TO_DYNAMIC_ZONE': case 'ADD_COMPONENT_TO_DYNAMIC_ZONE':
case 'ADD_REPEATABLE_COMPONENT_TO_FIELD': { case 'ADD_REPEATABLE_COMPONENT_TO_FIELD': {
const { keys, allComponents, componentLayoutData, shouldCheckErrors } = action; const {
keys,
allComponents,
componentLayoutData,
shouldCheckErrors,
position = undefined,
} = action;
if (shouldCheckErrors) { if (shouldCheckErrors) {
draftState.shouldCheckErrors = !state.shouldCheckErrors; draftState.shouldCheckErrors = !state.shouldCheckErrors;
@ -62,7 +68,15 @@ const reducer = (state, action) =>
draftState.modifiedDZName = keys[0]; draftState.modifiedDZName = keys[0];
} }
const currentValue = get(state, ['modifiedData', ...keys], []); const currentValue = [...get(state, ['modifiedData', ...keys], [])];
let actualPosition = position;
if (actualPosition === undefined) {
actualPosition = currentValue.length;
} else if (actualPosition < 0) {
actualPosition = 0;
}
const defaultDataStructure = const defaultDataStructure =
action.type === 'ADD_COMPONENT_TO_DYNAMIC_ZONE' action.type === 'ADD_COMPONENT_TO_DYNAMIC_ZONE'
@ -87,11 +101,9 @@ const reducer = (state, action) =>
componentLayoutData.attributes componentLayoutData.attributes
); );
const newValue = Array.isArray(currentValue) currentValue.splice(actualPosition, 0, componentDataStructure);
? [...currentValue, componentDataStructure]
: [componentDataStructure];
set(draftState, ['modifiedData', ...keys], newValue); set(draftState, ['modifiedData', ...keys], currentValue);
break; break;
} }

View File

@ -851,6 +851,78 @@ describe('CONTENT MANAGER | COMPONENTS | EditViewDataManagerProvider | reducer',
expect(reducer(state, action)).toEqual(expected); expect(reducer(state, action)).toEqual(expected);
}); });
it('should add a component at a specific position in the array', () => {
const components = {
'blog.simple': {
uid: 'blog.simple',
attributes: {
id: {
type: 'integer',
},
name: {
type: 'string',
},
},
},
};
const state = {
...initialState,
componentsDataStructure: {
'blog.simple': { name: 'test' },
},
initialData: {
name: 'name',
dz: [{ name: 'test', __component: 'blog.simple', id: 0 }],
},
modifiedData: {
name: 'name',
dz: [{ name: 'test', __component: 'blog.simple', id: 0 }],
},
};
const expected = {
...initialState,
componentsDataStructure: {
'blog.simple': { name: 'test' },
},
initialData: {
name: 'name',
dz: [{ name: 'test', __component: 'blog.simple', id: 0 }],
},
modifiedData: {
name: 'name',
dz: [
{ name: 'test', __component: 'blog.simple', __temp_key__: 1 },
{ name: 'test', __component: 'blog.simple', id: 0 },
],
},
modifiedDZName: 'dz',
shouldCheckErrors: true,
};
const action = {
type: 'ADD_COMPONENT_TO_DYNAMIC_ZONE',
componentLayoutData: {
uid: 'blog.simple',
attributes: {
id: {
type: 'integer',
},
name: {
type: 'string',
},
},
},
allComponents: components,
keys: ['dz'],
shouldCheckErrors: true,
position: -1,
};
expect(reducer(state, action)).toEqual(expected);
});
}); });
describe('CONNECT_RELATION', () => { describe('CONNECT_RELATION', () => {

View File

@ -13,7 +13,7 @@ import { Pencil, Layer } from '@strapi/icons';
import InformationBox from 'ee_else_ce/content-manager/pages/EditView/InformationBox'; import InformationBox from 'ee_else_ce/content-manager/pages/EditView/InformationBox';
import { InjectionZone } from '../../../shared/components'; import { InjectionZone } from '../../../shared/components';
import permissions from '../../../permissions'; import permissions from '../../../permissions';
import DynamicZone from '../../components/DynamicZone'; import { DynamicZone } from '../../components/DynamicZone';
import CollectionTypeFormWrapper from '../../components/CollectionTypeFormWrapper'; import CollectionTypeFormWrapper from '../../components/CollectionTypeFormWrapper';
import EditViewDataManagerProvider from '../../components/EditViewDataManagerProvider'; import EditViewDataManagerProvider from '../../components/EditViewDataManagerProvider';
import SingleTypeFormWrapper from '../../components/SingleTypeFormWrapper'; import SingleTypeFormWrapper from '../../components/SingleTypeFormWrapper';