mirror of
				https://github.com/strapi/strapi.git
				synced 2025-10-31 09:56:44 +00:00 
			
		
		
		
	Merge pull request #20453 from strapi/v5/expand-nested-components
Expand nested components
This commit is contained in:
		
						commit
						cf77fef5b1
					
				| @ -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) => { | ||||||
|  | |||||||
| @ -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 }; | ||||||
|  | |||||||
| @ -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; | ||||||
|  | }; | ||||||
|  | |||||||
| @ -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', | ||||||
|  |             }, | ||||||
|  |           ], | ||||||
|  |         }, | ||||||
|  |       ]); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  | |||||||
| @ -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); | ||||||
|   }); |   }); | ||||||
|  | |||||||
| @ -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]; | ||||||
|  | |||||||
| @ -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; | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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; | ||||||
|  | |||||||
| @ -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; | ||||||
|  | }; | ||||||
| @ -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); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -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(), | ||||||
|       }; |       }; | ||||||
|  | |||||||
| @ -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); | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Bassel Kanso
						Bassel Kanso