ADOBE EXPERIENCE MANAGER (AEM) COMPONENTS

Cascade Select Dropdown in AEM Component Dialog

This tutorial demonstrates how to populate CoralUI Select dropdowns in an AEM component dialog from a JSON data source using JavaScript. For added complexity, we’re cascading the dropdowns in multiple instances within a Multifield component.

Getting Started

The code for this tutorial was developed on AEM version 6.5.6.0 which is the latest at the time of this writing. To replicate the project, you can download or clone the source code or create the project using the AEM Project Archetype version 24 Maven template as I have done.

Component Dialog

Rather than create a new component, we’re just going to modify the existing helloworld component that is included with the project.

In the helloworld dialog .content.xml, add the following component nodes after the existing text component node.

1. Heading component (optional)

<heading
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/heading"
    text="Cars"
    level="3"/>

2. Composite Multifield component

<cars
    jcr:primaryType="nt:unstructured"
    sling:resourceType="granite/ui/components/coral/foundation/form/multifield"
    composite="{Boolean}true">
    <field
        jcr:primaryType="nt:unstructured"
        sling:resourceType="granite/ui/components/coral/foundation/container"
        name="./cars">
        <items jcr:primaryType="nt:unstructured">
            <make
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/form/select"
                emptyText="Select a make"
                fieldLabel="Car"
                name="./make">
            </make>
            <model
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/form/select"
                emptyText="Select a model"
                name="./model">
            </model>
        </items>
    </field>
</cars>

The Multifield needs to be composite to store the cars jcr:content as item nodes.

Cars Data

A endpoint that returns a JSON response could be used instead, but for this proof-of-concept, we’re just adding a static JSON file resource to the existing clientlib-base. Create the folders resources/data in clientlib-base and add this cars.json file to it. e.g.,

clientlib-base/resources/data/cars.json
{
  "cars": [{
     "make": "Chevy",
      "models": ["Camaro Z28", "Camaro ZL1", "Chevelle SS 454",  "Nova SS"]
    },{
      "make": "Dodge",
      "models": ["Challenger", "Charger Daytona", "Dart 426 Hemi"]
    },{
      "make": "Ford",
      "models": ["Fairlane Torino GT", "Mustang Boss 429", "Mustang Mach 1", "Mustang Shelby GT500", "Talladega", "Torino Cobra"]
    }
  ]
}

Dialog JavaScript

To add JavaScript that will be used by our component dialog, we’re going to create a clientlib-edit folder with a jcr:primaryType of cq:ClientLibraryFolder in our component folder. To ensure that the library is only loaded for authoring dialogs, it needs to have its category set to cq.authoring.dialog. Additionally, to use the ClientLibraryProxyServlet that allows us to modularize our client libraries under /apps, set the allowProxy property. For example,

helloworld/clientlib-edit/.content.xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0"
          jcr:primaryType="cq:ClientLibraryFolder"
          allowProxy="{Boolean}true"
          categories="[cq.authoring.dialog]"
          dependencies="[cq.jquery]"/>

Note the cq.jquery dependency. This is for just a couple jQuery methods included in our dialog.js and since it is already being loaded by AEM for its authoring environment, there is no additional overhead. Most of the script is using vanilla JS for modern browsers. As for the couple of jQuery functions, they could be refactored to vanilla JS pretty easily.

Include js.txt manifest file.

clientlib-edit/js.txt
dialog.js

The dialog.js JavaScript that includes the Coral UI API object.

clientlib-edit/dialog.js
(function ($, $document, Coral) {
    $document.on("dialog-ready", function() {

        console.log('====== DIALOG READY ======');

        const form = document.querySelector('form.cq-dialog');

        var carsfield = {},
            carsdata = [];

        function getCars() {
            $.ajax({
                url: `/etc.clientlibs/myproject/clientlibs/clientlib-base/resources/data/cars.json`,
                async: true,
                dataType: 'json',
                success: function (data) {
                    carsdata = data.cars;
                }
            });
        }

        function getContent(i) {
            var content = {};
            $.ajax({
                url: `${form.action}/cars/item${i}.json`,
                async: false,
                dataType: 'json',
                success: function (data) {
                    content.make = data.make;
                    content.model = data.model;
                }
            });
            return content;
        }

        function getElementIndex(node) {
            var index = 0;
            while ( (node = node.previousElementSibling) ) {
                index++;
            }
            return index;
        }

        function getModels(make) {
            for (var i = 0, len = carsdata.length; i < len; ++i) {
                if (make == carsdata[i].make) {
                    return carsdata[i].models;
                }
            }
        }

        function populateModel(select, make) {
            var models = getModels(make);
            Coral.commons.ready(select, function (component) {
                component.items.clear();
                models.forEach(function (model) {
                    var option = document.createElement('coral-select-item');
                    option.textContent = model;
                    component.items.add(option);
                });
            });
        }

        /**
         * @param {*} select (element)
         * @param {*} content (jcr:content)
         */
        function populateItem(select, key, content) {
            for (var i = 0, len = carsdata.length; i < len; ++i) {
                if (key == 'make') {
                    var option = document.createElement('coral-select-item');
                    option.textContent = carsdata[i].make
                    select.appendChild(option);
                } else if (content.make == carsdata[i].make) { // models
                    carsdata[i].models.forEach(function (model) {
                        var option = document.createElement('coral-select-item');
                        option.textContent = model;
                        select.appendChild(option);
                    });
                }
            }

            Coral.commons.ready(select, function (component) {
                if (key == 'make') {
                    if (content) {
                        component.value = content.make;
                    }
                    component.addEventListener('change', function(evt) {
                        // cascade selection
                        var item = select.closest('[role=listitem]');
                        var i = getElementIndex(item);
                        var select2 = item.querySelector(`coral-select[name="./cars/item${i}/./model"]`);
                        populateModel(select2, component.value);
                    });
                } else if (content) {
                    component.value = content.model;
                }
            });
        }

        function populateItems() {
            for (var i = 0, len = carsfield.items.length; i < len; ++i) {
                var content = getContent(i);
                var select1 = carsfield.items[i].querySelector(`coral-select[name="./cars/item${i}/./make"]`);
                populateItem(select1, 'make', content);
                var select2 = carsfield.items[i].querySelector(`coral-select[name="./cars/item${i}/./model"]`);
                populateItem(select2, 'model', content);
            }
        }

        function init() {
            try {
                carsfield.root = form.querySelector('[data-granite-coral-multifield-name="./cars"]');
                carsfield.add = carsfield.root.querySelector('button[coral-multifield-add]');
                carsfield.items = carsfield.root.querySelectorAll('coral-multifield-item');
            }
            catch(err) {
                console.log(err.message + ', likely due to N/A component');
                return;
            }

            getCars();

            if (carsfield.items) {
                // give coral a sec to inject fields
                setTimeout(function(){
                    populateItems();
                }, 500);
            }

            carsfield.add.addEventListener('click', function() {
                // give coral a sec to inject fields
                setTimeout(function(){
                    carsfield.items = carsfield.root.querySelectorAll('coral-multifield-item');
                    var index = carsfield.items.length - 1,
                        select = carsfield.items[index].querySelector(`coral-select[name="./cars/item${index}/./make"]`);
                    populateItem(select, 'make', null);
                }, 500);
            });
        }

        init();
    });

})($, $(document), Coral);

Some notes about the client-side script that populates the dropdowns.

  • Note: The "dialog-ready" event does not fire when opened into fullscreen mode at browser viewport width less than 1024 pixels.
  • When the dialog is opened in another component that does not contain the cars multifield, the try catch statement is used to handle the exception for the undefined query selector result.
  • Coral.commons.ready method is used for the select elements to access their CoralUI API instance.
  • The dialog form element action attribute contains the base jcr:content path to use for getting the saved data and selecting their dropdown items.

Dialog extraClientlibs

The current setup we’re using includes our dialog.js in the global JavaScript for all dialogs. We can modify the dialog .content.xml for the helloworld component so it will include our dialog.js in it’s own clientlib category for authoring.

Step 1 Add the extraClientlibs property to define the clientlib categories to compile for the dialog. This property goes into the jcr:root node of the .content.xml. e.g.,

helloworld/_cq_dialog/.content.xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
    jcr:primaryType="nt:unstructured"
    jcr:title="Properties"
    extraClientlibs="[cars.authoring.dialog]"
    sling:resourceType="cq/gui/components/authoring/dialog">

Step 2 Replace the cq.authoring.dialog category with the extraClientLibs category defined in the dialog. e.g.,

helloworld/clientlib-edit/.content.xml
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0"
          jcr:primaryType="cq:ClientLibraryFolder"
          allowProxy="{Boolean}true"
          categories="[cars.authoring.dialog]"
          dependencies="[cq.jquery]"/>

Step 3 Move clientlib-edit out of the helloworld component folder and into the projects clientlibs and rename it clientlib-edit-cars. This is optional. e.g.,

cd src/myproject/ui.apps/src/main/content/jcr_root/apps/myproject

mv components/helloworld/clientlib-edit clientlibs/clientlib-edit-cars

Now the dialog clientlib is loaded using http://localhost:4502/etc.clientlibs/myproject/clientlibs/clientlib-edit-cars.js

Instead of http://localhost:4502/libs/cq/gui/components/authoring/dialog/clientlibs/all.js


Source Code
comments powered by Disqus