Skip to main content

Adobe

Composite Components in AEM SPA (React)

Coding

About Composite Components

Composite components are combinations of multiple components that reside or can be accessed within a single parent/container component. The main goal of developing components in such a way is to make life easier for authors by being able to drag and drop only one component rather than multiple. It also helps in building reusable modular components that can function independently or as a part of other composite components. In some cases, the parent/container component has its own dialog while in others it does not. The authors can individually edit each of the components as they would if they were added to the page as a standalone component. 

Composite Components in SPA Versus Non-SPA

Developing composite components in a non-SPA/HTL world is pretty straightforward. All we need to do is add the resource path of the child component using data-sly-resource inside the HTL of the parent component. We’ll touch more on this below. 

To achieve the same on AEM SPA architecture it involves a few more steps. This is because only the JSON data of the authored components is exposed via the Sling model exporter while the React component does the heavy lifting of mapping to them and reading the data. If the JSON does not have a nested structure (child components listed under the parent) the React component does not know that we are trying to build a composite component. Once we have the nested structure available, we can iterate and initialize each as a standalone React component.  

More details are mentioned below in the implementation section. 

Implementing in Non-SPA 

The HTL/non-SPA way of building this component can be achieved by the following steps.  

If the parent/container component does not have a dialog or design dialog, we need to create a cq:template node of type nt:unstructuredand under cq:template node., mMake a node of type nt:unstructuredwith sling:resourceType having the value of the path to the component included.  

Repeat the same for all the child components. In the HTL of the parent/container component add the tag below to simply include the component at render time: 

<sly data-sly-resource=”${‘<component name>’ @ resourceType=’<resource path of the component>’ }”/> 

Below is an example of a title and image (composite) component built using a core title and image. The parent/container component does not have a dialog, so we need to create a cq:template as mentioned above. The HTL will need to include the image and title components by using data-sly-resource. 

 <sly data-sly-resource=”${‘title’ @ resourceType=’weretail/components/content/title’ }”/> 

 <sly data-sly-resource=”${‘image’ @ resourceType=’weretail/components/content/image}”/> 

Title And Image Component In Aem Spa

Title And Image Component In Aem Spa Htl

Implementing in AEM SPA Editor

When implementing this in AEM SPA Editor (React), the process is very different because the component is completely rendered as a part of the SPA application. As mentioned above, in AEM SPA the authored data is still stored in the JCR but exposed as JSON which gets mapped to its React counterpart rather than reading it in traditional HTL.  

We’ll showcase how we can build a header component in AEM SPA (React) by leveraging a mix of core and custom components. The parent component which is the header in this case will be updated to export the data in a nested (JSON) structure for all the components authored as its children. This will help the React component read the data as one element having child nodes and then iterate over them. After this, we need to include the object returned by each node which will then initialize the child component. The child components are: 

  • Logo (Core Image)
  • Primary Navigation (Core Navigation)
  • Secondary Navigation
    • Search
    • My Account

Second Navigation Child Components

This implementation requires us to build:

  1. Proxies of Core Image & Core Navigation
  2. Search & My Account Components
  3. An ExportChildComponents sling model
  4. A parent/container header component

1. Proxies of Core Image & Core Navigation

  • Create a proxy of the core image and update the title as a logo.
  • Create a proxy of the core navigation component.
  • For the sake of this demo, the proxies do not have any customizations.

Proxies Of Core Image And Core Navigation

2. Search & My Account Components

  • Create a standalone search component.
  • It can be reused in the header as well as dragged and dropped anywhere.
  • Create a My Account component with fields to configure account management.

3. An ExportChildComponents Sling model

  • The export Child Components Sling model implements the Core Container Sling model.
import com.adobe.cq.export.json.ComponentExporter;
import com.drew.lang.annotations.NotNull;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Exporter;
import com.adobe.cq.export.json.ExporterConstants;
import com.adobe.cq.wcm.core.components.models.Container;
import java.util.Map;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.HashMap;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.injectorspecific.*;
import org.apache.sling.models.factory.ModelFactory;
import com.adobe.cq.export.json.SlingModelFilter;

//Sling Model annotation 
@Model(adaptables = SlingHttpServletRequest.class, 
       adapters = { ExportChildComponents.class,ComponentExporter.class },
       defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
       
@Exporter( // Exporter annotation that serializes the model as JSON
        name = ExporterConstants.SLING_MODEL_EXPORTER_NAME, 
        extensions = ExporterConstants.SLING_MODEL_EXTENSION, 
        selector = ExporterConstants.SLING_MODEL_SELECTOR)

public class ExportChildComponents implements Container{
       
    private Map<String, ? extends ComponentExporter> childrenModels;
    private String[] exportedItemsOrder;

    @ScriptVariable
    private Resource resource;
    
    @Self
    private SlingHttpServletRequest request;

    @OSGiService
    private SlingModelFilter slingModelFilter;

    @OSGiService
    private ModelFactory modelFactory;
    
    @NotNull
    @Override
    public Map<String, ? extends ComponentExporter> getExportedItems() {
        if (childrenModels == null) {
            childrenModels = getChildrenModels(request, ComponentExporter.class);
        }
        Map<String, ComponentExporter> exportChildrenModels = new HashMap<String, ComponentExporter>();
        exportChildrenModels.putAll(childrenModels);
        return exportChildrenModels;
    }

    @NotNull
    @Override
    public String[] getExportedItemsOrder() {
        if (exportedItemsOrder == null) {
            Map<String, ? extends ComponentExporter> models = getExportedItems();
            if (!models.isEmpty()) {
                exportedItemsOrder = models.keySet().toArray(ArrayUtils.EMPTY_STRING_ARRAY);
            } else {
                exportedItemsOrder = ArrayUtils.EMPTY_STRING_ARRAY;
            }
        }
        return Arrays.copyOf(exportedItemsOrder,exportedItemsOrder.length);
    }

    private  Map<String, T> getChildrenModels(@NotNull SlingHttpServletRequest request, @NotNull Class
            modelClass) {
        Map<String, T> models = new LinkedHashMap<>();
        for (Resource child : slingModelFilter.filterChildResources(resource.getChildren())) {
            T model = modelFactory.getModelFromWrappedRequest(request, child, modelClass);
            if (model != null) {
                models.put(child.getName(), model);
            }
        }
        return models;
    }
}
...
  • It helps to iterate over child components saved under the parent node.
  • Then export all of them as a single JSON output via their Sling model exporter.
import com.adobe.cq.export.json.ComponentExporter;
{
    ":items": {
      "root": {
        "columnClassNames": {
          "header": "aem-GridColumn aem-GridColumn--default--12"
        },
        "gridClassNames": "aem-Grid aem-Grid--12 aem-Grid--default--12",
        "columnCount": 12,
        ":items": {
          "header": {
            "id": "header-346949959",
            ":itemsOrder": [
              "logo",
              "navigation",
              "search",
              "account"
            ],
            ":type": "perficient/components/header",
            ":items": {
              "logo": {
                "id": "image-ee58d4cd48",
                "linkType": "",
                "tagLinkType": "",
                "displayPopupTitle": true,
                "decorative": false,
                "srcUriTemplate": "/content/experience-fragments/perficient/us/en/site/header/master/_jcr_content/root/header/logo.coreimg{.width}.png/1705295980301/perficient-logo-horizontal.png",
                "lazyEnabled": true,
                "title": "Logo",
                "uuid": "799c7831-264c-42c0-be02-1d0cbb747bd2",
                "areas": [],
                "alt": "NA",
                "src": "/content/experience-fragments/perficient/us/en/site/header/master/_jcr_content/root/header/logo.coreimg.png/1705295980301/perficient-logo-horizontal.png",
                "widths": [],
                ":type": "perficient/components/logo"
              },
              "navigation": {
                "id": "navigation-1727181688",
                "items": [
                  {
                    "id": "navigation-cd54619f8f-item-12976048d7",
                    "path": "/content/react-spa/us/en/cases",
                    "level": 0,
                    "active": false,
                    "current": false,
                    "title": "Link 1",
                    "url": "/content/react-spa/us/en/cases.html",
                    "lastModified": 1705203487624,
                    ":type": "perficient/components/structure/page"
                  },
                  {
                    "id": "navigation-cd54619f8f-item-438eb66728",
                    "path": "/content/react-spa/us/en/my-onboarding-cases",
                    "level": 0,
                    "active": false,
                    "current": false,
                    "title": "Link 2",
                    "url": "/content/react-spa/us/en/my-onboarding-cases.html",
                    "lastModified": 1705203496764,
                    ":type": "perficient/components/structure/page"
                  },
                  {
                    "id": "navigation-cd54619f8f-item-e8d85a3188",
                    "path": "/content/react-spa/us/en/learning-resources",
                    "level": 0,
                    "active": false,
                    "current": false,
                    "title": "Link 3",
                    "url": "/content/react-spa/us/en/learning-resources.html",
                    "lastModified": 1705203510651,
                    ":type": "perficient/components/structure/page"
                  },
                  {
                    "id": "navigation-cd54619f8f-item-d1553683aa",
                    "path": "/content/react-spa/us/en/Reports",
                    "children": [],
                    "level": 0,
                    "active": false,
                    "current": false,
                    "title": "Link 5",
                    "url": "/content/react-spa/us/en/Reports.html",
                    "lastModified": 1705203601382,
                    ":type": "perficient/components/structure/page"
                  }
                ],
                ":type": "perficient/components/navigation"
              },
              "search": {
                "id": "search-1116154524",
                "searchFieldType": "navbar",
                ":type": "perficient/components/search"
              },
              "account": {
                "id": "account-1390371799",
                "accountConfigurationFields": [],
                "logoutLinkType": "",
                "helpText": "Account",
                "logoutText": "Sign Out",
                ":type": "perficient/components/account"
              }
            }
          }
        },
        ":itemsOrder": [
          "header"
        ],
        ":type": "perficient/components/core/container"
      }
    }
  }
...

4. Parent/Container Header Component

  • Create a Header Component which is an extension of the core container component.

Header Component Aem Spa

  • Include the Search Component dialog as an additional tab in the header component dialog.

Search Component Dialog

  • Include Accounts tab which is a custom tab with fields for user account management.

Accounts Tab

  • Create cq:template having child nodes pointing to their corresponding resource paths.
  • This will help export the authored data using the Sling model exporter.

Sling Model Exporter

  • Create HeaderModelImpl that extends ExportChildComponents (created in step 3). This exports the JSON data of not only the parent but also its children (logo, navigation & search).
import com.adobe.cq.export.json.ComponentExporter;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Exporter;
import org.apache.sling.models.annotations.Model;
import com.adobe.cq.export.json.ComponentExporter;
import com.adobe.cq.export.json.ExporterConstants;
import com.chc.ecchub.core.models.ExportChildComponents;
import com.chc.ecchub.core.models.nextgen.header.HeaderModel;

@Model(adaptables = SlingHttpServletRequest.class,
        adapters = { HeaderModel.class, ComponentExporter.class},
        resourceType = HeaderModelImpl.RESOURCE_TYPE, 
        defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)

@Exporter( // Exporter annotation that serializes the model as JSON
        name = ExporterConstants.SLING_MODEL_EXPORTER_NAME, 
        extensions = ExporterConstants.SLING_MODEL_EXTENSION)
public class HeaderModelImpl extends ExportChildComponents implements HeaderModel{
    
    static final String RESOURCE_TYPE = "perficient/components/header";

    @Override
    public String getExportedType() {
        return RESOURCE_TYPE;
    }

}
...
  • Create React equivalent for a header that extends the core container and maps to the header component created above.
import { Container, MapTo, withComponentMappingContext } from '@adobe/aem-react-editable-components';
import { useState } from "react";
import { NavigationComp, NavigationConfig } from "./Navigation";
import { LogoComp, LogoConfig } from "../../components/Logo";
import { SearchComp } from "../../components/Search";
import { SecondaryNavigation } from "./SecondaryNavigation";

export const HeaderEditConfig = {
    emptyLabel: 'Header'
};

export default class Header extends Container {
    render() {
        return (
            <>
                <HeaderComp headerData={super.childComponents} headerProps={this.props} hasAuthorization={hasAuthorization}/>
            </>
        )
    }
}

const selectedComponent = (props, selectedCqType) => {
    let selectedComponentProps;
    props?.headerData?.forEach((data) => {
        if (data.props?.cqType === selectedCqType) {
            selectedComponentProps = data;
        }
    });
    return selectedComponentProps;
}


const HeaderComp = (props) => {
    const [searchModalOverlay, setSearchModalOverlay] = useState(false);
    const searchProps = selectedComponent(props, 'perficient/components/search');
    const logoProps = selectedComponent(props, 'perficient/components/logo');
    const navProps = selectedComponent(props, 'perficient/components/navigation');

    return (
        <>
            <header data-analytics-component="Header">
                <SearchComp
                    searchProps={searchProps?.props}
                    onToggleSearch={setSearchModalOverlay}
                    searchModal={searchModalOverlay}
                />
                <div className="header__container">
                    <div className="header__container-wrapper">
                        <div className="company-info">
                            {logoProps}
                        </div>
                        <div className="desktop-only">
                            <nav className="navigation">
                                {navProps}
                            </nav>
                            <nav className="header__secondary-nav">
                                <SecondaryNavigation
                                    notificationRead={notificationRead}
                                    isNotificationRead={isNotificationRead}
                                    snProps={props}
                                    onToggleSearch={setSearchModalOverlay}
                                />
                            </nav>
                        </div>
                    </div>
                </div>
            </header>
        </>
    )
}

MapTo('ECCHub/components/nextgen/header/navigation')(NavigationComp, NavigationConfig);
MapTo('ECCHub/components/nextgen/header/logo')(LogoComp, LogoConfig);
MapTo('ECCHub/components/nextgen/header/header')(withComponentMappingContext(Header), HeaderEditConfig);

 

  • Additionally, add a map for the logo and navigation since they are exported as child components. Since the header extends the core container the child component properties are available via super.childcomponents
import { useEffect } from 'react';
import { MapTo } from '@adobe/aem-react-editable-components';
import * as constants from '../../../../utils/constants';
import { toggleHeaderForUsers } from '../../../../utils/genericUtil';
import { useDispatch } from "react-redux";
import { BreadcrumbActions } from '../../../../store/Breadcrumb/BreadcrumbSlice';

export const NavigationConfig = {
    emptyLabel: 'Navigation',

    isEmpty: function() {
        return true;
    }
};

export const NavigationComp = (navProps) => {
    return (
            <ul className="top-menu" data-analytics-component="Top Navigation">
                {/* Maps through authored navigation links and renders the link title and URL. */}
                {navProps?.items.map((item, index) =>
                <li key={index}><a href={item?.url} title={item?.title}>{item?.title}</a></li>
                )}
            </ul>
    );
}

MapTo("perficient/components/navigation")(NavigationComp, NavigationConfig);

 

 

Super.childcomponents

Worth the Investment

Composite components are a useful feature to leverage as they immensely help the authors while retaining the modularity and reusability of the individual components. Although creating the experience on the SPA framework compared to the HTL approach requires additional effort it is still worth the investment as it is a one-time setup and can be used for any components any number of times as it is quite generic.

 

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Rahul Chutkay

Rahul is a Senior Technical Architect at Perficient. He has more than 10 years of experience working in AEM. Rahul also likes to learn about new web development technologies and take on different challenges that keep him on his toes.

More from this Author

Categories
Follow Us