diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/AddComponentButton.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/AddComponentButton.js
index b0caf3f7e9..9f0860eaba 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/AddComponentButton.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/AddComponentButton.js
@@ -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 (
+
+
+
+
+ {children}
+
+
+
+ );
+};
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 (
-
-
-
-
-
-
-
-
- {buttonLabel}
-
-
-
-
-
- );
-};
-
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;
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentCard.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentCard.js
deleted file mode 100644
index 9ee654ad98..0000000000
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentCard.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
- {children}
-
-
-
- );
-}
-
-ComponentCard.defaultProps = {
- onClick() {},
-};
-
-ComponentCard.propTypes = {
- children: PropTypes.node.isRequired,
- onClick: PropTypes.func,
-};
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentCategory.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentCategory.js
index 9d2634ca1e..811cee33c3 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentCategory.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentCategory.js
@@ -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
{components.map(({ componentUid, info: { displayName } }) => (
-
- {formatMessage({ id: displayName, defaultMessage: displayName })}
-
+
+
+
+
+
+ {formatMessage({ id: displayName, defaultMessage: displayName })}
+
+
+
))}
@@ -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;
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentPicker.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentPicker.js
index 8504dea90d..1eff918632 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentPicker.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/ComponentPicker.js
@@ -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 (
-
-
-
-
- {formatMessage({
- id: getTrad('components.DynamicZone.ComponentPicker-label'),
- defaultMessage: 'Pick one component',
- })}
-
-
-
-
- {dynamicComponentCategories.map(({ category, components }, index) => (
-
- ))}
-
-
+
+
+
+ {formatMessage({
+ id: getTrad('components.DynamicZone.ComponentPicker-label'),
+ defaultMessage: 'Pick one component',
+ })}
+
+
+
+
+ {Object.entries(dynamicComponentsByCategory).map(([category, components], index) => (
+
+ ))}
+
);
};
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;
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/DynamicComponent.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/DynamicComponent.js
index 21e81e9447..3f921356e9 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/DynamicComponent.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/DynamicComponent.js
@@ -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 = ({
>
+
+
+
+
+ {formatMessage({
+ id: getTrad('components.DynamicZone.more-actions'),
+ defaultMessage: 'More actions',
+ })}
+
+
+
+
+
+ {formatMessage({
+ id: getTrad('components.DynamicZone.add-item-above'),
+ defaultMessage: 'Add component above',
+ })}
+
+
+ {Object.entries(dynamicComponentsByCategory).map(([category, components]) => (
+
+ {category}
+ {components.map(({ componentUid, info: { displayName } }) => (
+
+ ))}
+
+ ))}
+
+
+
+
+ {formatMessage({
+ id: getTrad('components.DynamicZone.add-item-below'),
+ defaultMessage: 'Add component below',
+ })}
+
+
+ {Object.entries(dynamicComponentsByCategory).map(([category, components]) => (
+
+ {category}
+ {components.map(({ componentUid, info: { displayName } }) => (
+
+ ))}
+
+ ))}
+
+
+
+
);
return (
-
+
@@ -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;
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/DynamicZoneLabel.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/DynamicZoneLabel.js
index 9b8ffcee20..4df303ef28 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/DynamicZoneLabel.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/DynamicZoneLabel.js
@@ -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 (
-
-
-
-
-
- {intlLabel}
-
-
- ({numberOfComponents})
-
- {required && *}
- {labelAction && {labelAction}}
-
- {intlDescription && (
-
-
- {formatMessage(intlDescription)}
-
-
- )}
+
+
+
+
+ {intlLabel}
+
+
+ ({numberOfComponents})
+
+ {required && *}
+ {labelAction && {labelAction}}
-
+ {intlDescription && (
+
+
+ {formatMessage(intlDescription)}
+
+
+ )}
+
);
@@ -82,5 +76,3 @@ DynamicZoneLabel.propTypes = {
numberOfComponents: PropTypes.number,
required: PropTypes.bool,
};
-
-export default DynamicZoneLabel;
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/AddComponentButton.test.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/AddComponentButton.test.js
index acb2d154ad..87d6b50b98 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/AddComponentButton.test.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/AddComponentButton.test.js
@@ -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('', () => {
- const setup = (props) =>
- render(
-
-
-
-
-
- );
+ const render = (props) => ({
+ ...renderRTL(
+
+ test
+ ,
+ {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ }
+ ),
+ 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();
});
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentCard.test.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentCard.test.js
deleted file mode 100644
index d50b0feeea..0000000000
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentCard.test.js
+++ /dev/null
@@ -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(
-
- test
-
-
- );
-
- it('should call the onClick handler when passed', () => {
- const onClick = jest.fn();
- const { getByText } = setup({ onClick });
- fireEvent.click(getByText('test'));
- expect(onClick).toHaveBeenCalled();
- });
-});
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentCategory.test.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentCategory.test.js
index 239a0b9c15..4f9ad0b022 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentCategory.test.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentCategory.test.js
@@ -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(
-
-
-
-
-
- );
+ const render = (props) => ({
+ ...renderRTL(
+ ,
+ {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ }
+ ),
+ 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');
});
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentPicker.test.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentPicker.test.js
index 4e67cbf0d9..4078d91d97 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentPicker.test.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/ComponentPicker.test.js
@@ -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) => (
-
-
-
-
-
+
);
- const setup = (props) => render();
+ const render = (props) => ({
+ ...renderRTL(, {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ }),
+ 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();
- 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');
});
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/DynamicComponent.test.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/DynamicComponent.test.js
index b4ab46d92a..b002fa95d8 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/DynamicComponent.test.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/DynamicComponent.test.js
@@ -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 }) => (
-
-
-
-
- {testingDnd ? : null}
-
-
-
+ <>
+
+ {testingDnd ? : null}
+ >
);
- const setup = (props) => render();
-
- 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(, {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ }),
+ 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');
});
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/DynamicZoneLabel.test.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/DynamicZoneLabel.test.js
index 970ca04ced..dc211ebabd 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/DynamicZoneLabel.test.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/DynamicZoneLabel.test.js
@@ -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', () => {
);
- const setup = (props) => render();
+ const render = (props) => renderRTL();
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();
- 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: });
+ const { getByLabelText } = render({ 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();
});
});
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/fixtures.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/fixtures.js
index 49f3b6e0fc..233748d70c 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/fixtures.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/components/tests/fixtures.js
@@ -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,
+ },
+ },
+ ],
+};
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/index.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/index.js
index 9c1b928af2..9de9043c10 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/index.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/index.js
@@ -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}>>}
+ */
+ 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 (
{formatMessage({
@@ -194,7 +263,7 @@ const DynamicZone = ({
{liveText}
{dynamicDisplayedComponents.map(({ componentUid, id }, index) => (
-
))}
)}
-
-
+
+
+ {renderButtonLabel()}
+
+
@@ -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 };
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/tests/index.test.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/tests/index.test.js
index cb416e998b..a6bdd21942 100644
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/tests/index.test.js
+++ b/packages/core/admin/admin/src/content-manager/components/DynamicZone/tests/index.test.js
@@ -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) => (
-
-
-
-
-
-
-
- );
+ const TestComponent = (props) => ;
- const setup = (props) => render();
+ const render = (props) => ({
+ ...renderRTL(, {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ }),
+ 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();
});
});
});
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/utils/connect.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/utils/connect.js
deleted file mode 100644
index eb1b2cd222..0000000000
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/utils/connect.js
+++ /dev/null
@@ -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 ;
- };
-}
-
-export default connect;
diff --git a/packages/core/admin/admin/src/content-manager/components/DynamicZone/utils/select.js b/packages/core/admin/admin/src/content-manager/components/DynamicZone/utils/select.js
deleted file mode 100644
index c04f0b8030..0000000000
--- a/packages/core/admin/admin/src/content-manager/components/DynamicZone/utils/select.js
+++ /dev/null
@@ -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;
diff --git a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/index.js b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/index.js
index 92d7401767..95696226f0 100644
--- a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/index.js
+++ b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/index.js
@@ -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,
});
},
diff --git a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/reducer.js b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/reducer.js
index b418195601..7153d84c20 100644
--- a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/reducer.js
+++ b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/reducer.js
@@ -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;
}
diff --git a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/tests/reducer.test.js b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/tests/reducer.test.js
index 996529974a..aaac98cea0 100644
--- a/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/tests/reducer.test.js
+++ b/packages/core/admin/admin/src/content-manager/components/EditViewDataManagerProvider/tests/reducer.test.js
@@ -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', () => {
diff --git a/packages/core/admin/admin/src/content-manager/pages/EditView/index.js b/packages/core/admin/admin/src/content-manager/pages/EditView/index.js
index 836aba2076..16dca54fd8 100644
--- a/packages/core/admin/admin/src/content-manager/pages/EditView/index.js
+++ b/packages/core/admin/admin/src/content-manager/pages/EditView/index.js
@@ -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';