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 ourdialog.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