mirror of
https://github.com/strapi/strapi.git
synced 2025-11-02 02:44:55 +00:00
feat: add dynamic components above and below (#16826)
Co-authored-by: Gustav Hansen <gustav.hansen@strapi.io>
This commit is contained in:
parent
6bd5d14d47
commit
aba64850a5
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
</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}
|
||||
</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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user