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

View File

@ -1,18 +1,25 @@
import get from 'lodash/get';
import { makeUnique } from '../../../utils/makeUnique';
import type { Component, AttributeType, Components } from '../../../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 componentsThatHaveNestedComponents = Object.keys(allComponents).reduce(
(acc: Internal.UID.Component[], current) => {
(acc: ComponentWithChildren[], current) => {
const currentComponent = get(allComponents, [current]);
const uid = currentComponent.uid;
if (doesComponentHaveAComponentField(currentComponent)) {
acc.push(uid);
const compoWithChildren = getComponentWithChildComponents(currentComponent);
if (compoWithChildren.childComponents.length > 0) {
acc.push(compoWithChildren);
}
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[];
return {
component: component.uid,
childComponents: attributes
.filter((attribute) => {
const { type } = attribute;
return attributes.some((attribute) => {
const { type } = attribute;
return type === 'component';
});
return type === 'component';
})
.map((attribute) => {
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) => {
const nestedComponents = Object.keys(appComponents).reduce((acc: any, current) => {
export type NestedComponent = {
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 currentComponentNestedCompos = getComponentsFromComponent(componentAttributes);
const currentComponentNestedCompos = getComponentsNestedWithinComponent(
componentAttributes,
current as Internal.UID.Component
);
return [...acc, ...currentComponentNestedCompos];
}, []);
return makeUnique(nestedComponents);
return mergeComponents(nestedComponents);
};
const getComponentsFromComponent = (componentAttributes: any) => {
return componentAttributes.reduce((acc: any, current: any) => {
const getComponentsNestedWithinComponent = (
componentAttributes: AttributeType[],
parentCompoUid: Internal.UID.Component
) => {
return componentAttributes.reduce((acc: NestedComponent[], current) => {
const { type, component } = current;
if (type === 'component') {
acc.push(component);
acc.push({
component,
parentCompoUid,
});
}
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 {
doesComponentHaveAComponentField,
getComponentWithChildComponents,
retrieveComponentsThatHaveComponents,
} from '../retrieveComponentsThatHaveComponents';
@ -80,18 +80,37 @@ const data: any = {
describe('retrieveComponentsThatHaveComponents', () => {
describe('doesComponentHaveAComponentField', () => {
it('Should return true if one of its attributes is a component', () => {
expect(doesComponentHaveAComponentField(data['blog.slider'])).toBe(true);
it('Should return correct child component if component has a component', () => {
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', () => {
expect(doesComponentHaveAComponentField(data['default.dish'])).toBe(false);
it('Should return no child components if component has no child components', () => {
expect(getComponentWithChildComponents(data['default.dish'])).toEqual({
component: 'default.dish',
childComponents: [],
});
});
});
describe('retrievComponentsThatHaveComponents', () => {
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 type { Components } from '../../../../types';
describe('CONTENT TYPE BUILDER | COMPONENTS | DataManagerProvider | utils | retrieveNestedComponents', () => {
it('should return an array of nested components', () => {
const components = {
const components: Components = {
'default.closingperiod': {
uid: 'default.closingperiod',
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);
});

View File

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

View File

@ -1,8 +1,11 @@
import { SingleSelectOption, SingleSelect, Field } from '@strapi/design-system';
import { useIntl } from 'react-intl';
import { MAX_COMPONENT_DEPTH } from '../constants';
import { useDataManager } from '../hooks/useDataManager';
import { getChildrenMaxDepth, getComponentDepth } from '../utils/getMaxDepth';
import type { Internal } from '@strapi/types';
interface Option {
uid: string;
label: string;
@ -22,7 +25,7 @@ interface SelectComponentProps {
isCreatingComponentWhileAddingAField: boolean;
name: string;
onChange: (value: any) => void;
targetUid: string;
targetUid: Internal.UID.Schema;
value: string;
forTarget: string;
}
@ -44,8 +47,11 @@ export const SelectComponent = ({
const errorMessage = error ? formatMessage({ id: error, defaultMessage: error }) : '';
const label = formatMessage(intlLabel);
const { componentsGroupedByCategory, componentsThatHaveOtherComponentInTheirAttributes } =
useDataManager();
const {
componentsGroupedByCategory,
componentsThatHaveOtherComponentInTheirAttributes,
nestedComponents,
} = useDataManager();
const isTargetAComponent = ['component', 'components'].includes(forTarget);
@ -66,8 +72,11 @@ export const SelectComponent = ({
);
if (isAddingAComponentToAnotherComponent) {
options = options.filter((option) => {
return !componentsThatHaveOtherComponentInTheirAttributes.includes(option.uid);
options = options.filter(({ uid }: any) => {
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
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 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 {
validators,
@ -47,10 +46,7 @@ export const getTypeValidator = (
} as any);
};
const getTypeShape = (
attribute: Schema.Attribute.AnyAttribute,
{ modelType, attributes }: any = {}
) => {
const getTypeShape = (attribute: Schema.Attribute.AnyAttribute, { attributes }: any = {}) => {
switch (attribute.type) {
/**
* complex types
@ -219,24 +215,8 @@ const getTypeShape = (
return {
required: validators.required,
repeatable: yup.boolean(),
component: yup
.string()
.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(),
// TODO: Add correct server validation for nested components
component: yup.string().required(),
min: yup.number(),
max: yup.number(),
};

View File

@ -1,17 +1,9 @@
import _ from 'lodash';
import utils, { errors } from '@strapi/utils';
import type { Schema, Struct } from '@strapi/types';
import type { Schema } from '@strapi/types';
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) =>
_.get(attribute, 'configurable', true);