Merge pull request #20453 from strapi/v5/expand-nested-components

Expand nested components
This commit is contained in:
Bassel Kanso 2024-06-24 10:22:53 +03:00 committed by GitHub
commit cf77fef5b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 455 additions and 80 deletions

View File

@ -22,7 +22,6 @@ import { DataManagerContext } from '../../contexts/DataManagerContext';
import { useFormModalNavigation } from '../../hooks/useFormModalNavigation'; import { useFormModalNavigation } from '../../hooks/useFormModalNavigation';
import { pluginId } from '../../pluginId'; import { pluginId } from '../../pluginId';
import { getTrad } from '../../utils/getTrad'; import { getTrad } from '../../utils/getTrad';
import { makeUnique } from '../../utils/makeUnique';
import { useAutoReloadOverlayBlocker } from '../AutoReloadOverlayBlocker'; import { useAutoReloadOverlayBlocker } from '../AutoReloadOverlayBlocker';
import { FormModal } from '../FormModal/FormModal'; import { FormModal } from '../FormModal/FormModal';
@ -420,14 +419,13 @@ const DataManagerProvider = ({ children }: DataManagerProviderProps) => {
const composWithCompos = retrieveComponentsThatHaveComponents(allCompos); const composWithCompos = retrieveComponentsThatHaveComponents(allCompos);
return makeUnique(composWithCompos); return composWithCompos;
}; };
const getAllNestedComponents = () => { const getAllNestedComponents = () => {
const appNestedCompo = retrieveNestedComponents(components); const appNestedCompo = retrieveNestedComponents(components);
const editingDataNestedCompos = retrieveNestedComponents(modifiedData.components || {});
return makeUnique([...editingDataNestedCompos, ...appNestedCompo]); return appNestedCompo;
}; };
const removeComponentFromDynamicZone = (dzName: string, componentToRemoveIndex: number) => { const removeComponentFromDynamicZone = (dzName: string, componentToRemoveIndex: number) => {

View File

@ -1,18 +1,25 @@
import get from 'lodash/get'; import get from 'lodash/get';
import { makeUnique } from '../../../utils/makeUnique';
import type { Component, AttributeType, Components } from '../../../types'; import type { Component, AttributeType, Components } from '../../../types';
import type { Internal } from '@strapi/types'; import type { Internal } from '@strapi/types';
type ChildComponent = {
component: Internal.UID.Component;
};
export type ComponentWithChildren = {
component: Internal.UID.Component;
childComponents: ChildComponent[];
};
const retrieveComponentsThatHaveComponents = (allComponents: Components) => { const retrieveComponentsThatHaveComponents = (allComponents: Components) => {
const componentsThatHaveNestedComponents = Object.keys(allComponents).reduce( const componentsThatHaveNestedComponents = Object.keys(allComponents).reduce(
(acc: Internal.UID.Component[], current) => { (acc: ComponentWithChildren[], current) => {
const currentComponent = get(allComponents, [current]); const currentComponent = get(allComponents, [current]);
const uid = currentComponent.uid;
if (doesComponentHaveAComponentField(currentComponent)) { const compoWithChildren = getComponentWithChildComponents(currentComponent);
acc.push(uid); if (compoWithChildren.childComponents.length > 0) {
acc.push(compoWithChildren);
} }
return acc; return acc;
@ -20,17 +27,25 @@ const retrieveComponentsThatHaveComponents = (allComponents: Components) => {
[] []
); );
return makeUnique(componentsThatHaveNestedComponents); return componentsThatHaveNestedComponents;
}; };
const doesComponentHaveAComponentField = (component: Component) => { const getComponentWithChildComponents = (component: Component): ComponentWithChildren => {
const attributes = get(component, ['schema', 'attributes'], []) as AttributeType[]; const attributes = get(component, ['schema', 'attributes'], []) as AttributeType[];
return {
component: component.uid,
childComponents: attributes
.filter((attribute) => {
const { type } = attribute;
return attributes.some((attribute) => { return type === 'component';
const { type } = attribute; })
.map((attribute) => {
return type === 'component'; return {
}); component: attribute.component,
} as ChildComponent;
}),
};
}; };
export { doesComponentHaveAComponentField, retrieveComponentsThatHaveComponents }; export { getComponentWithChildComponents, retrieveComponentsThatHaveComponents };

View File

@ -1,24 +1,60 @@
import { makeUnique } from '../../../utils/makeUnique'; import type { Components, AttributeType } from '../../../types';
import type { Internal } from '@strapi/types';
export const retrieveNestedComponents = (appComponents: any) => { export type NestedComponent = {
const nestedComponents = Object.keys(appComponents).reduce((acc: any, current) => { component: Internal.UID.Component;
uidsOfAllParents?: Internal.UID.Component[];
parentCompoUid?: Internal.UID.Component;
};
export const retrieveNestedComponents = (appComponents: Components): NestedComponent[] => {
const nestedComponents = Object.keys(appComponents).reduce((acc: NestedComponent[], current) => {
const componentAttributes = appComponents?.[current]?.schema?.attributes ?? []; const componentAttributes = appComponents?.[current]?.schema?.attributes ?? [];
const currentComponentNestedCompos = getComponentsFromComponent(componentAttributes); const currentComponentNestedCompos = getComponentsNestedWithinComponent(
componentAttributes,
current as Internal.UID.Component
);
return [...acc, ...currentComponentNestedCompos]; return [...acc, ...currentComponentNestedCompos];
}, []); }, []);
return makeUnique(nestedComponents); return mergeComponents(nestedComponents);
}; };
const getComponentsFromComponent = (componentAttributes: any) => { const getComponentsNestedWithinComponent = (
return componentAttributes.reduce((acc: any, current: any) => { componentAttributes: AttributeType[],
parentCompoUid: Internal.UID.Component
) => {
return componentAttributes.reduce((acc: NestedComponent[], current) => {
const { type, component } = current; const { type, component } = current;
if (type === 'component') { if (type === 'component') {
acc.push(component); acc.push({
component,
parentCompoUid,
});
} }
return acc; return acc;
}, []); }, []);
}; };
// Merge duplicate components
const mergeComponents = (originalComponents: NestedComponent[]): NestedComponent[] => {
const componentMap = new Map();
// Populate the map with component and its parents
originalComponents.forEach(({ component, parentCompoUid }) => {
if (!componentMap.has(component)) {
componentMap.set(component, new Set());
}
componentMap.get(component).add(parentCompoUid);
});
// Convert the map to the desired array format
const transformedComponents: NestedComponent[] = Array.from(componentMap.entries()).map(
([component, parentCompoUidSet]) => ({
component,
uidsOfAllParents: Array.from(parentCompoUidSet),
})
);
return transformedComponents;
};

View File

@ -1,5 +1,5 @@
import { import {
doesComponentHaveAComponentField, getComponentWithChildComponents,
retrieveComponentsThatHaveComponents, retrieveComponentsThatHaveComponents,
} from '../retrieveComponentsThatHaveComponents'; } from '../retrieveComponentsThatHaveComponents';
@ -80,18 +80,37 @@ const data: any = {
describe('retrieveComponentsThatHaveComponents', () => { describe('retrieveComponentsThatHaveComponents', () => {
describe('doesComponentHaveAComponentField', () => { describe('doesComponentHaveAComponentField', () => {
it('Should return true if one of its attributes is a component', () => { it('Should return correct child component if component has a component', () => {
expect(doesComponentHaveAComponentField(data['blog.slider'])).toBe(true); expect(getComponentWithChildComponents(data['blog.slider'])).toEqual({
component: 'blog.slider',
childComponents: [
{
component: 'default.slide',
},
],
});
}); });
it('Should return false if none of its attributes is a component', () => { it('Should return no child components if component has no child components', () => {
expect(doesComponentHaveAComponentField(data['default.dish'])).toBe(false); expect(getComponentWithChildComponents(data['default.dish'])).toEqual({
component: 'default.dish',
childComponents: [],
});
}); });
}); });
describe('retrievComponentsThatHaveComponents', () => { describe('retrievComponentsThatHaveComponents', () => {
it('should return an array with all the components that have nested components', () => { it('should return an array with all the components that have nested components', () => {
expect(retrieveComponentsThatHaveComponents(data)).toEqual(['blog.slider']); expect(retrieveComponentsThatHaveComponents(data)).toEqual([
{
component: 'blog.slider',
childComponents: [
{
component: 'default.slide',
},
],
},
]);
}); });
}); });
}); });

View File

@ -1,8 +1,10 @@
import { retrieveNestedComponents } from '../retrieveNestedComponents'; import { retrieveNestedComponents } from '../retrieveNestedComponents';
import type { Components } from '../../../../types';
describe('CONTENT TYPE BUILDER | COMPONENTS | DataManagerProvider | utils | retrieveNestedComponents', () => { describe('CONTENT TYPE BUILDER | COMPONENTS | DataManagerProvider | utils | retrieveNestedComponents', () => {
it('should return an array of nested components', () => { it('should return an array of nested components', () => {
const components = { const components: Components = {
'default.closingperiod': { 'default.closingperiod': {
uid: 'default.closingperiod', uid: 'default.closingperiod',
category: 'default', category: 'default',
@ -49,7 +51,83 @@ describe('CONTENT TYPE BUILDER | COMPONENTS | DataManagerProvider | utils | retr
}, },
}; };
const expected = ['default.dish']; const expected = [
{
component: 'default.dish',
uidsOfAllParents: ['default.closingperiod'],
},
];
expect(retrieveNestedComponents(components)).toEqual(expected);
});
it('should return both parents', () => {
const components: Components = {
'default.closingperiod': {
uid: 'default.closingperiod',
category: 'default',
apiId: 'closingperiod',
schema: {
icon: 'angry',
name: 'closingperiod',
description: '',
collectionName: 'components_closingperiods',
attributes: [
{ type: 'string', name: 'label' },
{ type: 'date', required: true, name: 'start_date' },
{ type: 'date', required: true, name: 'end_date' },
{ type: 'media', multiple: false, required: false, name: 'media' },
{ component: 'default.dish', type: 'component', name: 'dish' },
],
},
},
'default.dish': {
uid: 'default.dish',
category: 'default',
apiId: 'dish',
schema: {
icon: 'address-book',
name: 'dish',
description: '',
collectionName: 'components_dishes',
attributes: [
{ type: 'string', required: false, default: 'My super dish', name: 'name' },
{ type: 'text', name: 'description' },
{ type: 'float', name: 'price' },
{ type: 'media', multiple: false, required: false, name: 'picture' },
{ type: 'richtext', name: 'very_long_description' },
{
type: 'relation',
relation: 'oneToOne',
target: 'api::category.category',
targetAttribute: null,
private: false,
name: 'categories',
},
],
},
},
'default.openingperiod': {
uid: 'default.openingperiod',
category: 'default',
apiId: 'openingperiod',
schema: {
icon: 'angry',
name: 'openingperiod',
description: '',
collectionName: 'components_openingperiods',
attributes: [{ component: 'default.dish', type: 'component', name: 'dish' }],
},
},
};
const expected = [
{
component: 'default.dish',
uidsOfAllParents: ['default.closingperiod', 'default.openingperiod'],
},
];
expect(retrieveNestedComponents(components)).toEqual(expected); expect(retrieveNestedComponents(components)).toEqual(expected);
}); });

View File

@ -1,10 +1,14 @@
import { MAX_COMPONENT_DEPTH } from '../../../constants';
import { getComponentDepth } from '../../../utils/getMaxDepth';
import type { IconByType } from '../../AttributeIcon'; import type { IconByType } from '../../AttributeIcon';
import type { NestedComponent } from '../../DataManagerProvider/utils/retrieveNestedComponents';
import type { Internal } from '@strapi/types'; import type { Internal } from '@strapi/types';
export const getAttributesToDisplay = ( export const getAttributesToDisplay = (
dataTarget = '', dataTarget = '',
targetUid: Internal.UID.Schema, targetUid: Internal.UID.Schema,
nestedComponents: Array<Internal.UID.Schema> nestedComponents: Array<NestedComponent>
): IconByType[][] => { ): IconByType[][] => {
const defaultAttributes: IconByType[] = [ const defaultAttributes: IconByType[] = [
'text', 'text',
@ -22,9 +26,6 @@ export const getAttributesToDisplay = (
]; ];
const isPickingAttributeForAContentType = dataTarget === 'contentType'; const isPickingAttributeForAContentType = dataTarget === 'contentType';
const isNestedInAnotherComponent = nestedComponents.includes(targetUid);
const canAddComponentInAnotherComponent =
!isPickingAttributeForAContentType && !isNestedInAnotherComponent;
if (isPickingAttributeForAContentType) { if (isPickingAttributeForAContentType) {
return [ return [
@ -34,8 +35,15 @@ export const getAttributesToDisplay = (
]; ];
} }
if (canAddComponentInAnotherComponent) { // this will only run when adding attributes to components
return [defaultAttributes, ['component']]; if (dataTarget) {
const componentDepth = getComponentDepth(targetUid, nestedComponents);
const isNestedInAnotherComponent = componentDepth >= MAX_COMPONENT_DEPTH;
const canAddComponentInAnotherComponent =
!isPickingAttributeForAContentType && !isNestedInAnotherComponent;
if (canAddComponentInAnotherComponent) {
return [defaultAttributes, ['component']];
}
} }
return [defaultAttributes]; return [defaultAttributes];

View File

@ -1,8 +1,11 @@
import { SingleSelectOption, SingleSelect, Field } from '@strapi/design-system'; import { SingleSelectOption, SingleSelect, Field } from '@strapi/design-system';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { MAX_COMPONENT_DEPTH } from '../constants';
import { useDataManager } from '../hooks/useDataManager'; import { useDataManager } from '../hooks/useDataManager';
import { getChildrenMaxDepth, getComponentDepth } from '../utils/getMaxDepth';
import type { Internal } from '@strapi/types';
interface Option { interface Option {
uid: string; uid: string;
label: string; label: string;
@ -22,7 +25,7 @@ interface SelectComponentProps {
isCreatingComponentWhileAddingAField: boolean; isCreatingComponentWhileAddingAField: boolean;
name: string; name: string;
onChange: (value: any) => void; onChange: (value: any) => void;
targetUid: string; targetUid: Internal.UID.Schema;
value: string; value: string;
forTarget: string; forTarget: string;
} }
@ -44,8 +47,11 @@ export const SelectComponent = ({
const errorMessage = error ? formatMessage({ id: error, defaultMessage: error }) : ''; const errorMessage = error ? formatMessage({ id: error, defaultMessage: error }) : '';
const label = formatMessage(intlLabel); const label = formatMessage(intlLabel);
const { componentsGroupedByCategory, componentsThatHaveOtherComponentInTheirAttributes } = const {
useDataManager(); componentsGroupedByCategory,
componentsThatHaveOtherComponentInTheirAttributes,
nestedComponents,
} = useDataManager();
const isTargetAComponent = ['component', 'components'].includes(forTarget); const isTargetAComponent = ['component', 'components'].includes(forTarget);
@ -66,8 +72,11 @@ export const SelectComponent = ({
); );
if (isAddingAComponentToAnotherComponent) { if (isAddingAComponentToAnotherComponent) {
options = options.filter((option) => { options = options.filter(({ uid }: any) => {
return !componentsThatHaveOtherComponentInTheirAttributes.includes(option.uid); const maxDepth = getChildrenMaxDepth(uid, componentsThatHaveOtherComponentInTheirAttributes);
const componentDepth = getComponentDepth(targetUid, nestedComponents);
const totalDepth = maxDepth + componentDepth;
return totalDepth <= MAX_COMPONENT_DEPTH;
}); });
} }

View File

@ -5,3 +5,5 @@ export const PERMISSIONS = {
// plugin directly in the browser // plugin directly in the browser
main: [{ action: 'plugin::content-type-builder.read', subject: null }], main: [{ action: 'plugin::content-type-builder.read', subject: null }],
}; };
export const MAX_COMPONENT_DEPTH = 6;

View File

@ -0,0 +1,95 @@
import type { ComponentWithChildren } from '../components/DataManagerProvider/utils/retrieveComponentsThatHaveComponents';
import type { NestedComponent } from '../components/DataManagerProvider/utils/retrieveNestedComponents';
import type { Internal } from '@strapi/types';
const findComponent = <T extends { component: Internal.UID.Component }>(
componentUid: Internal.UID.Schema,
components: Array<T>
) => {
return components.find((c) => c.component === componentUid);
};
/**
* Recursively calculates the maximum depth of nested child components
* for a given component UID.
*
* @param componentUid - The UID of the component to start from.
* @param components - The array of all components with their child components.
* @param currentDepth - The current depth of the recursion. Defaults to 0.
* @returns The maximum depth of the nested child components.
*/
export const getChildrenMaxDepth = (
componentUid: Internal.UID.Component,
components: Array<ComponentWithChildren>,
currentDepth = 0
) => {
const component = findComponent(componentUid, components);
// If the component doesn't exist or has no child components, return the current depth.
if (!component || !component.childComponents || component.childComponents.length === 0) {
return currentDepth;
}
let maxDepth = currentDepth;
// Iterate through each child component to calculate their respective depths.
component.childComponents.forEach((child) => {
// Recursively calculate the depth of the child component.
const depth = getChildrenMaxDepth(child.component, components, currentDepth + 1);
// Update the maximum depth if the child's depth is greater.
if (depth > maxDepth) {
maxDepth = depth;
}
});
return maxDepth;
};
/**
* Calculates the depth of a component within a nested component tree.
* Depth is defined as the level at which the component is nested.
* For example, a component at Depth 3 is the third nested component.
*
* @param component - The UID of the component to find the depth for.
* @param components - The array of all nested components.
* @returns The depth level of the component within the nested tree.
*/
export const getComponentDepth = (
component: Internal.UID.Schema,
components: Array<NestedComponent>
) => {
/**
* Helper function to recursively calculate the depth of a component.
*
* @param currentComponent - The current component being inspected.
* @param currentLevel - The current level of depth in the tree.
* @returns An array of depth levels found for the component.
*/
const getDepth = (currentComponent: NestedComponent, currentLevel: number): Array<number> => {
const levels = [];
levels.push(currentLevel);
// If the component has no parent UIDs, return the current levels
if (!currentComponent.uidsOfAllParents) {
return levels;
}
// Iterate over each parent UID to calculate their respective depths
for (const parentUid of currentComponent.uidsOfAllParents) {
const parentComponent = findComponent(parentUid, components);
if (parentComponent) {
levels.push(...getDepth(parentComponent, currentLevel + 1));
}
}
return levels;
};
const nestedCompo = findComponent(component, components);
// return depth 0 if component is not nested
if (!nestedCompo) {
return 0;
}
const compoDepth = Math.max(...getDepth(nestedCompo, 1));
return compoDepth;
};

View File

@ -0,0 +1,143 @@
import { getChildrenMaxDepth, getComponentDepth } from '../getMaxDepth';
import type { ComponentWithChildren } from '../../components/DataManagerProvider/utils/retrieveComponentsThatHaveComponents';
import type { NestedComponent } from '../../components/DataManagerProvider/utils/retrieveNestedComponents';
const componentsWithChildComponents: Array<ComponentWithChildren> = [
{
component: 'basic.parent-compo',
childComponents: [
{
component: 'basic.nested-compo1',
},
],
},
{
component: 'basic.nested-compo5',
childComponents: [
{
component: 'basic.nested-compo6',
},
],
},
{
component: 'basic.nested-compo4',
childComponents: [
{
component: 'basic.nested-compo5',
},
],
},
{
component: 'basic.nested-compo3',
childComponents: [
{
component: 'basic.nested-compo4',
},
],
},
{
component: 'basic.nested-compo2',
childComponents: [
{
component: 'basic.nested-compo3',
},
],
},
{
component: 'basic.nested-compo1',
childComponents: [
{
component: 'basic.nested-compo2',
},
],
},
{
component: 'basic.another-parent-compo',
childComponents: [
{
component: 'basic.nested-compo6',
},
],
},
{
component: 'default.openingtimes',
childComponents: [
{
component: 'default.dish',
},
{
component: 'basic.nested-compo3',
},
],
},
{
component: 'default.closingperiod',
childComponents: [
{
component: 'default.dish',
},
],
},
];
const nestedComponents: Array<NestedComponent> = [
{
component: 'default.dish',
uidsOfAllParents: ['default.openingtimes', 'default.closingperiod'],
},
{
component: 'basic.nested-compo1',
uidsOfAllParents: ['basic.parent-compo'],
},
{
component: 'basic.nested-compo6',
uidsOfAllParents: ['basic.nested-compo5', 'basic.another-parent-compo'],
},
{
component: 'basic.nested-compo5',
uidsOfAllParents: ['basic.nested-compo4'],
},
{
component: 'basic.nested-compo4',
uidsOfAllParents: ['basic.nested-compo3'],
},
{
component: 'basic.nested-compo3',
uidsOfAllParents: ['basic.nested-compo2'],
},
{
component: 'basic.nested-compo2',
uidsOfAllParents: ['basic.nested-compo1'],
},
];
describe('Component Depth Calculations', () => {
describe('getMaxDepth', () => {
it('A component with no child component should have 0 max depth', () => {
const componentsMaxDepth = getChildrenMaxDepth(
'basic.nested-compo6',
componentsWithChildComponents
);
expect(componentsMaxDepth).toEqual(0);
});
it('should accurately give the max depth of components children', () => {
const componentsMaxDepth = getChildrenMaxDepth(
'default.openingtimes',
componentsWithChildComponents
);
expect(componentsMaxDepth).toEqual(4);
});
});
describe('getComponentDepth', () => {
it('A component depth should reflect its position in the component tree', () => {
expect(getComponentDepth('basic.nested-compo1', nestedComponents)).toEqual(1);
expect(getComponentDepth('basic.nested-compo4', nestedComponents)).toEqual(4);
expect(getComponentDepth('basic.nested-compo6', nestedComponents)).toEqual(6);
});
});
});

View File

@ -2,9 +2,8 @@ import _ from 'lodash';
import { yup } from '@strapi/utils'; import { yup } from '@strapi/utils';
import type { TestContext } from 'yup'; import type { TestContext } from 'yup';
import type { Schema, UID, Struct } from '@strapi/types'; import type { Schema, Struct } from '@strapi/types';
import { hasComponent } from '../../utils/attributes';
import { modelTypes, VALID_UID_TARGETS } from '../../services/constants'; import { modelTypes, VALID_UID_TARGETS } from '../../services/constants';
import { import {
validators, validators,
@ -47,10 +46,7 @@ export const getTypeValidator = (
} as any); } as any);
}; };
const getTypeShape = ( const getTypeShape = (attribute: Schema.Attribute.AnyAttribute, { attributes }: any = {}) => {
attribute: Schema.Attribute.AnyAttribute,
{ modelType, attributes }: any = {}
) => {
switch (attribute.type) { switch (attribute.type) {
/** /**
* complex types * complex types
@ -219,24 +215,8 @@ const getTypeShape = (
return { return {
required: validators.required, required: validators.required,
repeatable: yup.boolean(), repeatable: yup.boolean(),
component: yup // TODO: Add correct server validation for nested components
.string() component: yup.string().required(),
.test({
name: 'Check max component nesting is 1 lvl',
test(compoUID: unknown) {
const targetCompo = strapi.components[compoUID as UID.Component];
if (!targetCompo) return true; // ignore this error as it will fail beforehand
if (modelType === modelTypes.COMPONENT && hasComponent(targetCompo)) {
return this.createError({
path: this.path,
message: `${targetCompo.modelName} already is a nested component. You cannot have more than one level of nesting inside your components.`,
});
}
return true;
},
})
.required(),
min: yup.number(), min: yup.number(),
max: yup.number(), max: yup.number(),
}; };

View File

@ -1,17 +1,9 @@
import _ from 'lodash'; import _ from 'lodash';
import utils, { errors } from '@strapi/utils'; import utils, { errors } from '@strapi/utils';
import type { Schema, Struct } from '@strapi/types'; import type { Schema } from '@strapi/types';
const { ApplicationError } = errors; const { ApplicationError } = errors;
export const hasComponent = (model: Struct.Schema) => {
const compoKeys = Object.keys(model.attributes || {}).filter((key) => {
return model.attributes[key].type === 'component';
});
return compoKeys.length > 0;
};
export const isConfigurable = (attribute: Schema.Attribute.AnyAttribute) => export const isConfigurable = (attribute: Schema.Attribute.AnyAttribute) =>
_.get(attribute, 'configurable', true); _.get(attribute, 'configurable', true);