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 PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import styled from 'styled-components';
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)`
height: ${({ theme }) => theme.spaces[6]};
width: ${({ theme }) => theme.spaces[6]};
transform: ${({ $isOpen }) => ($isOpen ? 'rotate(45deg)' : 'rotate(0deg)')};
> circle {
fill: ${({ theme, $hasError }) =>
@ -28,25 +55,11 @@ const StyledAddIcon = styled(PlusCircle)`
const StyledButton = styled(BaseButton)`
border-radius: 26px;
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};
svg {
height: ${({ theme }) => theme.spaces[6]};
width: ${({ theme }) => theme.spaces[6]};
> path {
fill: ${({ theme }) => theme.colors.neutral600};
}
}
&:hover {
color: ${({ theme }) => theme.colors.primary600} !important;
${Typography} {
color: ${({ theme }) => theme.colors.primary600} !important;
color: ${({ theme }) => theme.colors.primary600};
}
${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 = {
hasError: false,
hasMaxError: false,
hasMinError: false,
isDisabled: false,
isOpen: false,
label: '',
missingComponentNumber: 0,
};
AddComponentButton.propTypes = {
label: PropTypes.string,
children: PropTypes.node.isRequired,
hasError: PropTypes.bool,
hasMaxError: PropTypes.bool,
hasMinError: PropTypes.bool,
isDisabled: PropTypes.bool,
isOpen: PropTypes.bool,
missingComponentNumber: PropTypes.number,
name: PropTypes.string.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 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 { useIntl } from 'react-intl';
import ComponentCard from './ComponentCard';
import { ComponentIcon } from '../../ComponentIcon';
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, ${140 / 16}rem);
grid-gap: ${({ theme }) => theme.spaces[1]};
`;
const ComponentCategory = ({ category, components, variant, isOpen, onAddComponent, onToggle }) => {
export const ComponentCategory = ({
category,
components,
variant,
isOpen,
onAddComponent,
onToggle,
}) => {
const { formatMessage } = useIntl();
const handleToggle = () => {
@ -30,9 +39,26 @@ const ComponentCategory = ({ category, components, variant, isOpen, onAddCompone
<Box paddingTop={4} paddingBottom={4} paddingLeft={3} paddingRight={3}>
<Grid>
{components.map(({ componentUid, info: { displayName } }) => (
<ComponentCard key={componentUid} onClick={onAddComponent(componentUid)}>
{formatMessage({ id: displayName, defaultMessage: displayName })}
</ComponentCard>
<ComponentBox
key={componentUid}
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>
</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 = {
components: [],
isOpen: false,
@ -55,5 +105,3 @@ ComponentCategory.propTypes = {
onToggle: PropTypes.func.isRequired,
variant: PropTypes.oneOf(['primary', 'secondary']),
};
export default ComponentCategory;

View File

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

View File

@ -12,67 +12,18 @@ import {
IconButton,
Box,
Flex,
VisuallyHidden,
} from '@strapi/design-system';
import { Menu, MenuItem } from '@strapi/design-system/v2';
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 { composeRefs, getTrad, ItemTypes } from '../../../utils';
import FieldComponent from '../../FieldComponent';
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;
`;
const DynamicZoneComponent = ({
export const DynamicComponent = ({
componentUid,
formErrors,
index,
@ -83,6 +34,8 @@ const DynamicZoneComponent = ({
onGrabItem,
onDropItem,
onCancel,
dynamicComponentsByCategory,
onAddComponent,
}) => {
const [isOpen, setIsOpen] = useState(true);
const { formatMessage } = useIntl();
@ -180,11 +133,70 @@ const DynamicZoneComponent = ({
>
<Drag />
</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>
);
return (
<ComponentContainer as="li">
<ComponentContainer as="li" width="100%">
<Flex justifyContent="center">
<Rectangle background="neutral200" />
</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: {},
index: 0,
isFieldAllowed: true,
onAddComponent: undefined,
onGrabItem: undefined,
onDropItem: undefined,
onCancel: undefined,
};
DynamicZoneComponent.propTypes = {
DynamicComponent.propTypes = {
componentUid: PropTypes.string.isRequired,
dynamicComponentsByCategory: PropTypes.shape({
components: PropTypes.arrayOf(
PropTypes.shape({
componentUid: PropTypes.string.isRequired,
info: PropTypes.object,
})
),
}),
formErrors: PropTypes.object,
index: PropTypes.number,
isFieldAllowed: PropTypes.bool,
name: PropTypes.string.isRequired,
onAddComponent: PropTypes.func,
onGrabItem: PropTypes.func,
onDropItem: PropTypes.func,
onCancel: PropTypes.func,
onMoveComponent: PropTypes.func.isRequired,
onRemoveComponentClick: PropTypes.func.isRequired,
};
export default DynamicZoneComponent;

View File

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

View File

@ -1,66 +1,54 @@
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 { IntlProvider } from 'react-intl';
import AddComponentButton from '../AddComponentButton';
import { AddComponentButton } from '../AddComponentButton';
describe('<AddComponentButton />', () => {
const setup = (props) =>
render(
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<AddComponentButton label="test" name="name" onClick={jest.fn()} {...props} />
</IntlProvider>
</ThemeProvider>
);
const render = (props) => ({
...renderRTL(
<AddComponentButton onClick={jest.fn()} {...props}>
test
</AddComponentButton>,
{
wrapper: ({ children }) => (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
{children}
</IntlProvider>
</ThemeProvider>
),
}
),
user: userEvent.setup(),
});
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', () => {
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', () => {
it('should call the onClick handler when the button is clicked', async () => {
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();
});
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();
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();
});

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 { 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 { IntlProvider } from 'react-intl';
import ComponentCategory from '../ComponentCategory';
import { ComponentCategory } from '../ComponentCategory';
describe('ComponentCategory', () => {
const setup = (props) =>
render(
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<ComponentCategory
onAddComponent={jest.fn()}
onToggle={jest.fn()}
category="testing"
{...props}
/>
</IntlProvider>
</ThemeProvider>
);
const render = (props) => ({
...renderRTL(
<ComponentCategory
onAddComponent={jest.fn()}
onToggle={jest.fn()}
category="testing"
{...props}
/>,
{
wrapper: ({ children }) => (
<ThemeProvider theme={lightTheme}>
<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', () => {
setup({
const { getByRole } = render({
isOpen: true,
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', () => {
setup({
const { getByText } = render({
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();
setup({
const { getByRole, user } = render({
onToggle,
});
fireEvent.click(screen.getByText(/testing/));
await user.click(getByRole('button', { name: /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();
setup({
const { getByRole, user } = render({
isOpen: true,
onAddComponent,
components: [
@ -72,7 +80,7 @@ describe('ComponentCategory', () => {
],
});
fireEvent.click(screen.getByText(/myComponent/));
await user.click(getByRole('button', { name: /myComponent/ }));
expect(onAddComponent).toHaveBeenCalledWith('test');
});

View File

@ -1,70 +1,73 @@
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 { IntlProvider } from 'react-intl';
import ComponentPicker from '../ComponentPicker';
import { ComponentPicker } from '../ComponentPicker';
import { layoutData } from './fixtures';
jest.mock('../../../../hooks', () => ({
useContentTypeLayout: jest.fn().mockReturnValue({
getComponentLayout: jest.fn().mockImplementation((componentUid) => layoutData[componentUid]),
}),
}));
import { dynamicComponentsByCategory } from './fixtures';
describe('ComponentPicker', () => {
afterEach(() => {
jest.restoreAllMocks();
});
const Component = (props) => (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<ComponentPicker isOpen onClickAddComponent={jest.fn()} {...props} />
</IntlProvider>
</ThemeProvider>
<ComponentPicker
isOpen
onClickAddComponent={jest.fn()}
dynamicComponentsByCategory={dynamicComponentsByCategory}
{...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', () => {
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', () => {
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', () => {
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', () => {
const { rerender } = setup({
const { rerender, getByRole, queryByRole } = render({
isOpen: false,
});
rerender(<Component isOpen components={['component1', 'component2', 'component3']} />);
expect(screen.getByText(/component1/)).toBeInTheDocument();
expect(screen.queryByText(/component3/)).not.toBeInTheDocument();
expect(getByRole('button', { name: /component1/ })).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();
setup({
const { user, getByRole } = render({
components: ['component1', 'component2'],
onClickAddComponent,
});
fireEvent.click(screen.getByText(/component1/));
await user.click(getByRole('button', { name: /component1/ }));
expect(onClickAddComponent).toHaveBeenCalledWith('component1');
});

View File

@ -1,13 +1,14 @@
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 { IntlProvider } from 'react-intl';
import { DndProvider } from 'react-dnd';
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.requireActual('../../../../hooks'),
@ -42,65 +43,72 @@ describe('DynamicComponent', () => {
// eslint-disable-next-line react/prop-types
const TestComponent = ({ testingDnd, ...restProps }) => (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<DndProvider backend={HTML5Backend}>
<DynamicComponent {...defaultProps} {...restProps} />
{testingDnd ? <DynamicComponent {...defaultProps} {...restProps} /> : null}
</DndProvider>
</IntlProvider>
</ThemeProvider>
<>
<DynamicComponent {...defaultProps} {...restProps} />
{testingDnd ? <DynamicComponent {...defaultProps} {...restProps} /> : null}
</>
);
const setup = (props) => render(<TestComponent {...props} />);
it('should by default render the name of the component in the accordion trigger', () => {
setup();
expect(screen.getByRole('button', { name: 'component1' })).toBeInTheDocument();
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(),
});
it('should allow removal of the component & call the onRemoveComponentClick callback when the field isAllowed', () => {
const onRemoveComponentClick = jest.fn();
setup({ isFieldAllowed: true, onRemoveComponentClick });
it('should by default render the name of the component in the accordion trigger', () => {
const { getByRole } = render();
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();
});
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', () => {
setup();
it('should hide the field component when you close the accordion', async () => {
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', () => {
it('should not move with arrow keys if the button is not pressed first', () => {
const onMoveComponent = jest.fn();
setup({
const { getAllByText } = render({
onMoveComponent,
testingDnd: true,
});
const [draggedItem] = screen.getAllByText('Drag');
const [draggedItem] = getAllByText('Drag');
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
expect(onMoveComponent).not.toBeCalled();
});
it('should move with the arrow keys if the button has been activated first', () => {
const onMoveComponent = jest.fn();
setup({ onMoveComponent, testingDnd: true });
const [draggedItem] = screen.getAllByText('Drag');
const { getAllByText } = render({ onMoveComponent, testingDnd: true });
const [draggedItem] = getAllByText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
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', () => {
const onMoveComponent = jest.fn();
setup({ onMoveComponent, testingDnd: true });
const [draggedItem] = screen.getAllByText('Drag');
const { getAllByText } = render({ onMoveComponent, testingDnd: true });
const [draggedItem] = getAllByText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
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', () => {
const onMoveComponent = jest.fn();
setup({ onMoveComponent, testingDnd: true });
const [draggedItem] = screen.getAllByText('Drag');
const { getAllByText } = render({ onMoveComponent, testingDnd: true });
const [draggedItem] = getAllByText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'Escape', code: 'Escape' });
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');
});

View File

@ -1,11 +1,11 @@
import React from 'react';
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 { Earth } from '@strapi/icons';
import DynamicZoneLabel from '../DynamicZoneLabel';
import { DynamicZoneLabel } from '../DynamicZoneLabel';
const LabelAction = () => {
return (
@ -26,50 +26,50 @@ describe('DynamicZoneLabel', () => {
</ThemeProvider>
);
const setup = (props) => render(<Component {...props} />);
const render = (props) => renderRTL(<Component {...props} />);
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', () => {
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', () => {
const { rerender } = setup({ numberOfComponents: 0 });
const { rerender, getByText } = render({ numberOfComponents: 0 });
expect(screen.getByText(/0/)).toBeInTheDocument();
expect(getByText(/0/)).toBeInTheDocument();
rerender(<Component numberOfComponents={2} />);
expect(screen.getByText(/2/)).toBeInTheDocument();
expect(getByText(/2/)).toBeInTheDocument();
});
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', () => {
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', () => {
setup({
const { getByText } = render({
intlDescription: {
id: '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 get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import React, { useMemo, useState } from 'react';
import PropTypes from 'prop-types';
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 { getTrad } from '../../utils';
import connect from './utils/connect';
import select from './utils/select';
import DynamicZoneComponent from './components/DynamicComponent';
import AddComponentButton from './components/AddComponentButton';
import DynamicZoneLabel from './components/DynamicZoneLabel';
import ComponentPicker from './components/ComponentPicker';
import { DynamicComponent } from './components/DynamicComponent';
import { AddComponentButton } from './components/AddComponentButton';
import { DynamicZoneLabel } from './components/DynamicZoneLabel';
import { ComponentPicker } from './components/ComponentPicker';
import { useContentTypeLayout } from '../../hooks';
const DynamicZone = ({
name,
// Passed with the select function
addComponentToDynamicZone,
formErrors,
isCreatingEntry,
isFieldAllowed,
isFieldReadable,
labelAction,
moveComponentField,
removeComponentFromDynamicZone,
dynamicDisplayedComponents,
fieldSchema,
metadatas,
}) => {
const DynamicZone = ({ name, labelAction, fieldSchema, metadatas }) => {
// We cannot use the default props here
const { max = Infinity, min = -Infinity, components = [], required = false } = fieldSchema;
const [addComponentIsOpen, setAddComponentIsOpen] = useState(false);
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 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 intlDescription = metadatas.description
? { id: metadatas.description, defaultMessage: metadatas.description }
: null;
// We cannot use the default props here
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 dynamicZoneError = formErrors[name];
const missingComponentNumber = min - dynamicDisplayedComponentsLength;
const hasError = dynamicZoneErrors.length > 0;
const hasError = !!dynamicZoneError;
const hasMinError =
dynamicZoneErrors.length > 0 && get(dynamicZoneErrors, [0, 'id'], '').includes('min');
const hasMaxError =
hasError && get(dynamicZoneErrors, [0, 'id'], '') === 'components.Input.error.validation.max';
const handleAddComponent = (componentUid) => {
const handleAddComponent = (componentUid, position) => {
setAddComponentIsOpen(false);
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 = () => {
@ -160,6 +197,38 @@ const DynamicZone = ({
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))) {
return (
<NotAllowedInput
@ -183,7 +252,7 @@ const DynamicZone = ({
labelAction={labelAction}
name={name}
numberOfComponents={dynamicDisplayedComponentsLength}
required={fieldSchema.required || false}
required={required}
/>
<VisuallyHidden id={ariaDescriptionId}>
{formatMessage({
@ -194,7 +263,7 @@ const DynamicZone = ({
<VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>
<ol aria-describedby={ariaDescriptionId}>
{dynamicDisplayedComponents.map(({ componentUid, id }, index) => (
<DynamicZoneComponent
<DynamicComponent
componentUid={componentUid}
formErrors={formErrors}
key={`${componentUid}-${id}`}
@ -206,26 +275,26 @@ const DynamicZone = ({
onCancel={handleCancel}
onDropItem={handleDropItem}
onGrabItem={handleGrabItem}
onAddComponent={handleAddComponent}
dynamicComponentsByCategory={dynamicComponentsByCategory}
/>
))}
</ol>
</Box>
)}
<AddComponentButton
hasError={hasError}
hasMaxError={hasMaxError}
hasMinError={hasMinError}
isDisabled={!isFieldAllowed}
label={metadatas.label}
missingComponentNumber={missingComponentNumber}
isOpen={addComponentIsOpen}
name={name}
onClick={handleClickOpenPicker}
/>
<Flex justifyContent="center">
<AddComponentButton
hasError={hasError}
isDisabled={!isFieldAllowed}
isOpen={addComponentIsOpen}
onClick={handleClickOpenPicker}
>
{renderButtonLabel()}
</AddComponentButton>
</Flex>
<ComponentPicker
dynamicComponentsByCategory={dynamicComponentsByCategory}
isOpen={addComponentIsOpen}
components={fieldSchema.components ?? []}
onClickAddComponent={handleAddComponent}
/>
</Flex>
@ -233,44 +302,23 @@ const DynamicZone = ({
};
DynamicZone.defaultProps = {
dynamicDisplayedComponents: [],
fieldSchema: {
max: Infinity,
min: -Infinity,
},
fieldSchema: {},
labelAction: null,
};
DynamicZone.propTypes = {
addComponentToDynamicZone: PropTypes.func.isRequired,
dynamicDisplayedComponents: PropTypes.arrayOf(
PropTypes.shape({
componentUid: PropTypes.string.isRequired,
id: PropTypes.number.isRequired,
})
),
fieldSchema: PropTypes.shape({
components: PropTypes.array.isRequired,
components: PropTypes.array,
max: PropTypes.number,
min: PropTypes.number,
required: PropTypes.bool,
}),
formErrors: PropTypes.object.isRequired,
isCreatingEntry: PropTypes.bool.isRequired,
isFieldAllowed: PropTypes.bool.isRequired,
isFieldReadable: PropTypes.bool.isRequired,
labelAction: PropTypes.element,
metadatas: PropTypes.shape({
description: PropTypes.string,
label: PropTypes.string,
}).isRequired,
moveComponentField: PropTypes.func.isRequired,
name: PropTypes.string.isRequired,
removeComponentFromDynamicZone: PropTypes.func.isRequired,
};
const Memoized = memo(DynamicZone, isEqual);
export default connect(Memoized, select);
export { DynamicZone };

View File

@ -1,6 +1,8 @@
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 { useCMEditViewDataManager } from '@strapi/helper-plugin';
import { IntlProvider } from 'react-intl';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
@ -11,9 +13,25 @@ import { layoutData } from './fixtures';
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.requireActual('@strapi/helper-plugin'),
useCMEditViewDataManager: jest.fn().mockImplementation(() => ({ modifiedData: {} })),
useCMEditViewDataManager: jest.fn().mockImplementation(() => ({
...defaultCMEditViewMock,
})),
useNotification: jest.fn().mockImplementation(() => toggleNotification),
NotAllowedInput: () => 'This field is not allowed',
}));
@ -38,154 +56,193 @@ describe('DynamicZone', () => {
});
const defaultProps = {
addComponentToDynamicZone: jest.fn(),
isCreatingEntry: true,
isFieldAllowed: true,
isFieldReadable: true,
fieldSchema: {
components: ['component1', 'component2', 'component3'],
},
formErrors: {},
metadatas: {
label: 'dynamic zone',
description: 'dynamic description',
},
moveComponentField: jest.fn(),
name: 'DynamicZoneComponent',
removeComponentFromDynamicZone: jest.fn(),
};
const TestComponent = (props) => (
<ThemeProvider theme={lightTheme}>
<IntlProvider locale="en" messages={{}} defaultLocale="en">
<DndProvider backend={HTML5Backend}>
<DynamicZone {...defaultProps} {...props} />
</DndProvider>
</IntlProvider>
</ThemeProvider>
);
const TestComponent = (props) => <DynamicZone {...defaultProps} {...props} />;
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', () => {
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(screen.queryByText('dynamic description')).not.toBeInTheDocument();
expect(queryByText('dynamic zone')).not.toBeInTheDocument();
expect(queryByText('dynamic description')).not.toBeInTheDocument();
});
it('should render the AddComponentButton by default and render the ComponentPicker when that button is clicked', () => {
setup();
it('should render the AddComponentButton by default and render the ComponentPicker when that button is clicked', async () => {
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();
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', () => {
setup({
dynamicDisplayedComponents: [
{ componentUid: 'component1', id: 0 },
{ componentUid: 'component2', id: 0 },
],
});
useCMEditViewDataManager.mockImplementationOnce(() => ({
...defaultCMEditViewMock,
modifiedData: {
[TEST_NAME]: [
{
__component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
],
},
}));
const { getByText } = render();
expect(screen.getByText('dynamic zone')).toBeInTheDocument();
expect(screen.getByText('dynamic description')).toBeInTheDocument();
expect(getByText('dynamic zone')).toBeInTheDocument();
expect(getByText('dynamic description')).toBeInTheDocument();
expect(screen.getByText('component1')).toBeInTheDocument();
expect(screen.getByText('component2')).toBeInTheDocument();
expect(getByText('component1')).toBeInTheDocument();
expect(getByText('component2')).toBeInTheDocument();
});
it('should render the not allowed input if the field is not allowed & the entry is being created', () => {
setup({
isFieldAllowed: false,
useCMEditViewDataManager.mockImplementationOnce(() => ({
...defaultCMEditViewMock,
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', () => {
setup({
isFieldAllowed: false,
isCreatingEntry: false,
isFieldReadable: false,
});
useCMEditViewDataManager.mockImplementationOnce(() => ({
...defaultCMEditViewMock,
updateActionAllowedFields: [],
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', () => {
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();
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', {
name: /component1/i,
const componentPickerButton = getByRole('button', {
name: 'component1',
});
fireEvent.click(componentPickerButton);
await user.click(componentPickerButton);
expect(addComponentToDynamicZone).toHaveBeenCalledWith(
'DynamicZoneComponent',
{ category: 'myComponents', info: { displayName: 'component1', icon: undefined } },
undefined,
false
expect.any(Object),
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();
setup({
useCMEditViewDataManager.mockImplementationOnce(() => ({
...defaultCMEditViewMock,
removeComponentFromDynamicZone,
dynamicDisplayedComponents: [
{ componentUid: 'component1', id: 0 },
{ componentUid: 'component2', id: 0 },
],
});
modifiedData: {
[TEST_NAME]: [
{
__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);
});
});
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', () => {
setup({
dynamicDisplayedComponents: [
{ componentUid: 'component1', id: 0 },
{ componentUid: 'component2', id: 0 },
{ componentUid: 'component3', id: 0 },
],
it('should call the toggleNotification callback if the amount of dynamic components has hit its max and the user tries to add another', async () => {
useCMEditViewDataManager.mockImplementationOnce(() => ({
...defaultCMEditViewMock,
modifiedData: {
[TEST_NAME]: [
{
__component: 'component1',
id: 0,
},
{
__component: 'component2',
id: 0,
},
{
__component: 'component3',
id: 0,
},
],
},
}));
const { user, getByRole } = render({
fieldSchema: {
components: ['component1', 'component2', 'component3'],
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({
type: 'info',
@ -198,82 +255,188 @@ describe('DynamicZone', () => {
describe('Accessibility', () => {
it('should have have description text', () => {
setup({
dynamicDisplayedComponents: [
{ componentUid: 'component1', id: 0 },
{ componentUid: 'component2', id: 0 },
],
});
useCMEditViewDataManager.mockImplementationOnce(() => ({
...defaultCMEditViewMock,
modifiedData: {
[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 () => {
setup({
dynamicDisplayedComponents: [
{ componentUid: 'component1', id: 0 },
{ componentUid: 'component2', id: 0 },
],
});
useCMEditViewDataManager.mockImplementation(() => ({
...defaultCMEditViewMock,
modifiedData: {
[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(
screen.queryByText(
queryByText(
/Press up and down arrow to change position, Spacebar to drop, Escape to cancel/
)
).toBeInTheDocument();
});
it('should change the live text when an item has been moved', () => {
setup({
dynamicDisplayedComponents: [
{ componentUid: 'component1', id: 0 },
{ componentUid: 'component2', id: 0 },
],
});
it('should change the live text when an item has been moved', async () => {
useCMEditViewDataManager.mockImplementation(() => ({
...defaultCMEditViewMock,
modifiedData: {
[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' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
const [draggedItem] = getAllByRole('button', { name: 'Drag' });
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', () => {
setup({
dynamicDisplayedComponents: [
{ componentUid: 'component1', id: 0 },
{ componentUid: 'component2', id: 0 },
],
});
it('should change the live text when an item has been dropped', async () => {
useCMEditViewDataManager.mockImplementation(() => ({
...defaultCMEditViewMock,
modifiedData: {
[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' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
const [draggedItem] = getAllByRole('button', { name: 'Drag' });
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', () => {
setup({
dynamicDisplayedComponents: [
{ componentUid: 'component1', id: 0 },
{ componentUid: 'component2', id: 0 },
],
});
it('should change the live text after the reordering interaction has been cancelled', async () => {
useCMEditViewDataManager.mockImplementation(() => ({
...defaultCMEditViewMock,
modifiedData: {
[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' });
fireEvent.keyDown(draggedItem, { key: 'Escape', code: 'Escape' });
const [draggedItem] = getAllByRole('button', { name: 'Drag' });
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(
(type) =>
(keys, componentLayoutData, components, shouldCheckErrors = false) => {
(
keys,
componentLayoutData,
allComponents,
shouldCheckErrors = false,
position = undefined
) => {
trackUsageRef.current('didAddComponentToDynamicZone');
dispatch({
type,
keys: keys.split('.'),
position,
componentLayoutData,
allComponents: components,
allComponents,
shouldCheckErrors,
});
},

View File

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

View File

@ -851,6 +851,78 @@ describe('CONTENT MANAGER | COMPONENTS | EditViewDataManagerProvider | reducer',
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', () => {

View File

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