FAQ / Q&A using Content Fragments AEM

Problem Statement:

How can I get support for the Core Accordion component within the content fragment component?

Why should we use the Core Accordion component?

Requirement:

Get support for OOTB Accordion component within content fragment component

Introduction:

The Core Component Accordion component allows for the creation of a collection of components, composed as panels, and arranged in an accordion on a page, similar to the Tabs Component, but allows for expanding and collapsing of the panels.

  • The accordion’s properties can be defined in the configure dialog.
  • The order of the panels of the accordion can be defined in the configure dialog as well as the select panel popover.
  • Defaults for the Accordion Component when adding it to a page can be defined in the design dialog.

How to use Accordion for FAQ feature?

Manual authoring:

Drag and drop Accordion component, go to component dialog and click on add and select the component of your choice as shown below:

Accordion Dialog, insert panel

Once the component is selected add the question into the field

Author questions

Click on the panel selector icon and go to the individual item and make necessary changes

Panel Selection
Author answer

You can view it as published as shown below:

Published view of the Q&A

You can also rearrange the order of results from dialog

Dialog level reordering

Automated process:

Create a Sample model which can have a max of 10 Q&A’s as shown below:

Q&A content fragment

Create a custom Accordion component as shown below:

<div data-sly-use.accordion="com.adobe.cq.wcm.core.components.models.Accordion"
     data-sly-use.accordioncf="com.mysite.core.models.AccordionContentFragmentModel"
     data-panelcontainer="${wcmmode.edit && 'accordion'}"
     id="${accordion.id}"
     class="cmp-accordion"
     data-cmp-is="accordion"
     data-cmp-data-layer="${accordion.data.json}"
     data-cmp-single-expansion="${accordion.singleExpansion}"
     data-placeholder-text="${wcmmode.edit && 'Please drag Accordion item components here' @ i18n}">
    <h1>${accordioncf.faqTitle}</h1>
    <div data-sly-test="${accordion.items.size > 0}"
         data-sly-repeat.item="${accordion.items}"
         class="cmp-accordion__item"
         data-cmp-hook-accordion="item"
         data-cmp-data-layer="${item.data.json}"
         id="${item.id}"
         data-cmp-expanded="${item.name in accordion.expandedItems}">
        <h4 data-sly-element="${accordion.headingElement @ context='elementName'}"
            class="cmp-accordion__header">
            <button id="${item.id}-button"
                    class="cmp-accordion__button${item.name in accordion.expandedItems ? ' cmp-accordion__button--expanded' : ''}"
                    aria-controls="${item.id}-panel"
                    data-cmp-hook-accordion="button">
                <span class="cmp-accordion__title">${item.title}</span>
                <span class="cmp-accordion__icon"></span>
            </button>
        </h4>
        <div data-sly-resource="${item.name @ decorationTagName='div'}"
             data-cmp-hook-accordion="panel"
             id="${item.id}-panel"
             class="cmp-accordion__panel${item.name in accordion.expandedItems ? ' cmp-accordion__panel--expanded' : ' cmp-accordion__panel--hidden'}"
             role="region"
             aria-labelledby="${item.id}-button">
        </div>
    </div>
    <sly data-sly-resource="${resource.path @ resourceType='wcm/foundation/components/parsys/newpar', appendPath='/*', decorationTagName='div', cssClassName='new section aem-Grid-newComponent'}"
         data-sly-test="${(wcmmode.edit || wcmmode.preview) && accordion.items.size < 1}"></sly>
</div>

Create a Sling model interface called AccordionContentFragmentModel

package com.mysite.core.models;

import org.osgi.annotation.versioning.ConsumerType;

@ConsumerType
public interface AccordionContentFragmentModel {

    /**
     * Getter for checking editor template
     *
     * @return String content fragment FAQ Title
     */
    default String getFaqTitle() {
        throw new UnsupportedOperationException();
    }
}

Create a model implementation called as AccordionContentFragmentModelImpl

Make sure all the below code executes only on the author environment

We are checking for; how many content fragment fields are not empty and also we are checking for children’s under the current path.

If there is a difference, then we are going to delete the unwanted children’s and add the new children’s as shown below:

package com.mysite.core.models.impl;

import com.day.cq.commons.jcr.JcrConstants;
import com.day.cq.wcm.api.Page;
import com.mysite.core.models.AccordionContentFragmentModel;
import com.mysite.core.models.CFModel;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceUtil;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.OSGiService;
import org.apache.sling.models.annotations.injectorspecific.ScriptVariable;
import org.apache.sling.models.annotations.injectorspecific.Self;
import org.apache.sling.models.annotations.injectorspecific.SlingObject;
import org.apache.sling.models.factory.ModelFactory;
import org.apache.sling.settings.SlingSettingsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.StreamSupport;

@Model(adaptables = SlingHttpServletRequest.class, adapters = {AccordionContentFragmentModel.class})
public class AccordionContentFragmentModelImpl implements AccordionContentFragmentModel {

    private static final Logger LOG = LoggerFactory.getLogger(AccordionContentFragmentModelImpl.class);

    private static final String CF_FRAGMENT_PATH = "fragmentPath";
    private static final String CF_ELEMENTS_NAME = "elementNames";
    private static final String CF_FAQ_QUESTION = "question";
    private static final String CF_FAQ_ANSWER = "answer";
    private static final String CF_FAQ_ITEMS = "/item_";
    private static final String CF_FAQ_PANEL_TITLE = "cq:panelTitle";

    private static final String CF_ARTICLE_FRAGMENT_PATH = "articleFragmentPath";
    private static final String CF_FAQ_ITEM = "item_";
    private static final String UNDERSCORE = "_";
    private static final String PUBLISH_ENV = "publish";

    private static final Map<Integer, String> NUMBERS = new HashMap<>();
    static {
        NUMBERS.put(1, "One");
        NUMBERS.put(2, "Two");
        NUMBERS.put(3, "Three");
        NUMBERS.put(4, "Four");
        NUMBERS.put(5, "Five");
        NUMBERS.put(6, "Six");
        NUMBERS.put(7, "Seven");
        NUMBERS.put(8, "Eight");
        NUMBERS.put(9, "Nine");
        NUMBERS.put(10, "Ten");
    }

    static Map<String, Object> defualtFaqProperties = new HashMap<>();
    static {
        defualtFaqProperties.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_UNSTRUCTURED);
        defualtFaqProperties.put(ResourceResolver.PROPERTY_RESOURCE_TYPE, "mysite/components/contentfragment");
        defualtFaqProperties.put("containerOpted", "dynamic");
        defualtFaqProperties.put("displayMode", "singleText");
        defualtFaqProperties.put("paragraphScope", "all");
        defualtFaqProperties.put("variationName", "master");
    }

    @Self
    private SlingHttpServletRequest request;

    @ScriptVariable
    private Resource resource;

    @ScriptVariable
    private Page currentPage;

    @SlingObject
    private ResourceResolver resourceResolver;

    @OSGiService
    private SlingSettingsService slingSettings;

    @OSGiService
    private ModelFactory modelFactory;

    private List<Integer> faqCount = new ArrayList<>();

    @PostConstruct
    private void initModel() {
        if(!isPublish(slingSettings)) {
            String pageArticleFragmentPath = currentPage.getProperties().get(CF_ARTICLE_FRAGMENT_PATH, StringUtils.EMPTY);
            if(StringUtils.isNotEmpty(pageArticleFragmentPath)) {
                Resource contentFragmentResource = resourceResolver.resolve(pageArticleFragmentPath);
                CFModel cfModel = modelFactory.getModelFromWrappedRequest(request, contentFragmentResource, CFModel.class);
                getBodyElementCounts(cfModel);
                List<Integer> childrens = StreamSupport.stream(resource.getChildren().spliterator(), false)
                        .map(accRes -> StringUtils.substringAfter(accRes.getName(), UNDERSCORE))
                        .map(NumberUtils::toInt)
                        .sorted()
                        .collect(Collectors.toList());
                if(!faqCount.equals(childrens)) {
                    deleteInvalidResource(childrens);
                    addFaqItem(faqCount, resourceResolver, resource, pageArticleFragmentPath, cfModel);
                }
            }
        }
    }

    private void deleteInvalidResource(List<Integer> childrens) {
        childrens.removeAll(faqCount);
        if(!childrens.isEmpty()) {
            childrens.stream().forEach(this::deleteResource);
            commitChanges();
        }
    }
    private void commitChanges() {
        if(resourceResolver.hasChanges()) {
            try {
                resourceResolver.commit();
            } catch (PersistenceException e) {
                LOG.error("Unable save the cahnges {}",e.getMessage());
            }
        }
    }

    private void deleteResource(Integer num) {
        try {
            resourceResolver.delete(resource.getChild(CF_FAQ_ITEM+num));
        } catch (PersistenceException e) {
            LOG.error("Unable to delete the resource {}", e.getMessage());
        }
    }

    public void getBodyElementCounts(CFModel cfModel) {
        faqCount = IntStream.rangeClosed(1, 20).filter(num -> checkFragment(num, cfModel, CF_FAQ_QUESTION)).boxed().collect(Collectors.toList());
    }

    public static boolean checkFragment(int num, CFModel cfModel, String elementName) {
        return StringUtils.isNotEmpty(cfModel.getElementContent(elementName+ NUMBERS.get(num)));
    }

    public static void addFaqItem(List<Integer> faqCount, ResourceResolver resourceResolver, Resource resource, String pageArticleFragmentPath, CFModel cfModel) {
        while(!faqCount.isEmpty()) {
            try {
                Map<String, Object> faqProperties = defualtFaqProperties;
                faqProperties.put(CF_FRAGMENT_PATH, pageArticleFragmentPath);
                faqProperties.put(CF_ELEMENTS_NAME, CF_FAQ_ANSWER+ NUMBERS.get(faqCount.get(0)));
                faqProperties.put(CF_FAQ_PANEL_TITLE, cfModel.getElementContent(CF_FAQ_QUESTION+ NUMBERS.get(faqCount.get(0))));
                ResourceUtil.getOrCreateResource(resourceResolver, resource.getPath()+CF_FAQ_ITEMS+faqCount.get(0), faqProperties, StringUtils.EMPTY, false);
                faqCount.remove(0);
            } catch (PersistenceException e) {
                LOG.error("Unable to persist the resource {}", e.getMessage());
            }
        }
    }

    public static boolean isPublish(SlingSettingsService slingSettings) {
        return slingSettings.getRunModes().contains(PUBLISH_ENV);
    }
}

You can author the component by selecting the fragment path.

Notes: Please make sure we don’t author anything manually by clicking on add item

Author content fragment path

After authoring you can reload the page to see the content is being populated into the accordion model and all the changes made on the content fragment will be pulled into the component

Content fragment data is pulled automatically

You can also rearrange the order of the FAQ from dialog level or from the panel selector.

Rearrange from panel selector

Flexibility Vs Automation:

This component will automatically pull Q&A from content fragment but if there is any update on the question field then it won’t reflect hence please remove the field which is not pulling the latest results and refresh the page so that it pulls the latest.

Why are we seeing above issue?

We are unable to pull the updated questions into the component because this will remove the flexibility of rearranging the panels. Based on your requirement you can remove this functionality.

you can also find the working code from here:

https://github.com/kiransg89/AccordionContentFragment