AEM CQ 56 - Dynamic chain select widget with combo boxes

Goal


Create a widget to chain combo boxes and populate combo box data dynamically. Here the lower level combo boxes data change based on values selected in the higher levels. Source code, Package Install and Demo video are available for download. Please leave a comment if you find bugs




Solution


1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder (nt:folder) /apps/chainselect

2) Create folder (nt:folder) /apps/chainselect/install and deploy servlet GetDropDownData as OSGI component to return the data for combo boxes

package apps.mysample.chainselect;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.felix.scr.annotations.sling.SlingServlet;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.apache.sling.commons.json.io.JSONWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.ServletException;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

@SlingServlet(
        paths="/bin/mycomponents/chainselect/dropdowndata",
        methods = "GET",
        metatype = true,
        label = "Dropdown Data Servlet"
)
public class GetDropDownData extends SlingAllMethodsServlet {
    private static final Logger LOG = LoggerFactory.getLogger(GetDropDownData.class);

    private static Map<String, String> LEVEL_1 = new HashMap<String, String>();
    private static Map<String, Map<String, String>> LEVEL_2 = new HashMap<String, Map<String, String>>();
    private static Map<String, Map<String, String>> LEVEL_3 = new HashMap<String, Map<String, String>>();

    static{
        fillStaticData();
    }

    private static void fillStaticData(){
        LEVEL_1.put("ENTERTAINMENT", "Entertainment");
        LEVEL_1.put("HEALTH", "Health");
        LEVEL_1.put("PARTY", "Party");

        Map<String, String> map = new LinkedHashMap<String, String>();

        map.put("MOVIES", "Movies");
        map.put("CELEB_NEWS", "Celebrity News");
        map.put("TV", "TV");
        map.put("MUSIC", "Music");
        map.put("STYLE", "Style");

        LEVEL_2.put("ENTERTAINMENT", map);

        map = new LinkedHashMap<String, String>();

        map.put("MENS_HEALTH", "Men's Health");
        map.put("WOMENS_HEALTH", "Women's Health");
        map.put("CHILD_HEALTH", "Children's Health");
        map.put("ALT_MEDICINE", "Alternative Medicine");

        LEVEL_2.put("HEALTH", map);

        map = new LinkedHashMap<String, String>();

        map.put("HOLLYWOOD", "Hollywood");
        map.put("BOLLYWOOD", "Bollywood");

        LEVEL_3.put("MOVIES", map);

        map = new LinkedHashMap<String, String>();

        map.put("MJ", "Michael Jackson");
        map.put("RAHMAN", "A R Rahman");

        LEVEL_3.put("MUSIC", map);
    }

    private void ouputInitData(final SlingHttpServletRequest request, final SlingHttpServletResponse response)
                                throws ServletException{
        Integer level = NumberUtils.createInteger(request.getParameter("level"));
        String keyword = request.getParameter("keyword");

        if(level == null){
            level = 1;
        }

        try{
            JSONWriter jw = new JSONWriter(response.getWriter());
            Field field = null; Class clazz = this.getClass();
            Map<String, String> map = null;

            jw.object();

            do{
                try{
                    field = clazz.getDeclaredField("LEVEL_" + level);
                }catch (NoSuchFieldException nfe){
                    break;
                }

                if(level == 1){
                    map = (Map<String, String>)field.get(null);
                }else{
                    if(StringUtils.isEmpty(keyword)){
                        keyword = ((Map<String,Map<String, String>>)field.get(null)).keySet().iterator().next();
                    }

                    map = ((Map<String,Map<String, String>>)field.get(null)).get(keyword);
                }

                if(map == null){
                    break;
                }

                keyword = null;

                jw.key(level.toString()).array();

                for(Map.Entry<String, String> entry : map.entrySet()){
                    jw.array();
                    jw.value(entry.getKey()).value(entry.getValue());
                    jw.endArray();

                    if(StringUtils.isEmpty(keyword)){
                        keyword = entry.getKey();
                    }
                }

                jw.endArray();
                level++;
            }while(true);

            jw.endObject();
        }catch(Exception e){
            LOG.error("Error getting dropdown data",e);
            throw new ServletException(e);
        }
    }

    private void ouputSavedText(final SlingHttpServletRequest request, final SlingHttpServletResponse response)
            throws ServletException{
        try {
            String[] lStrs = request.getParameter("levels").split(",");
            String[] keywords = request.getParameter("keywords").split(",");
            JSONWriter jw = new JSONWriter(response.getWriter());

            Field field = null; Class clazz = this.getClass();
            Map<String, String> map = null; Integer level = null;

            jw.object();

            for(int i = 0; i < lStrs.length; i++){
                level = NumberUtils.createInteger(lStrs[i]);

                try{
                    field = clazz.getDeclaredField("LEVEL_" + level);
                }catch (NoSuchFieldException nfe){
                    continue;
                }

                if(level == 1){
                    map = (Map<String, String>)field.get(null);
                }else{
                    map = ((Map<String,Map<String, String>>)field.get(null)).get(keywords[i - 1]);
                }

                if(map == null){
                    continue;
                }

                jw.key(level.toString()).array();

                for(Map.Entry<String, String> entry : map.entrySet()){
                    jw.array();
                    jw.value(entry.getKey()).value(entry.getValue());
                    jw.endArray();
                }

                jw.endArray();
            }

            jw.endObject();
        } catch (Exception e) {
            LOG.error("Error getting dropdown data", e);
            throw new ServletException(e);
        }
    }

        @Override
    protected void doGet(final SlingHttpServletRequest request, final SlingHttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        String lStr = request.getParameter("levels");

        if(StringUtils.isNotEmpty(lStr)){
            ouputSavedText(request, response);
        }else{
            ouputInitData(request, response);
        }
    }
}

3) In the above servlet each level (LEVEL_1, LEVEL_2 etc) data structure has necessary data for combo box at that level. So when n-level combo boxes are created, necessary n-level data structures should also exist for serving them. A single ajax call returns the data for all combo boxes

4) Create clientlib (type cq:ClientLibraryFolder) /apps/chainselect/clientlib and set property categories with value cq.widgets

5) Create file (nt:file) /apps/chainselect/clientlib/js.txt, add

                     chainselect.js

6) Create file (nt:file) /apps/chainselect/clientlib/chainselect.js, add the following code

CQ.Ext.ns("ExperienceAEM");

ExperienceAEM.ChainSelect = CQ.Ext.extend(CQ.Ext.Panel, {
    panelValue: '',

    constructor: function(config){
        config = config || {};

        if(!config.levels){
            config.levels = "1";
        }

        config.levels = parseInt(config.levels, 10);

        ExperienceAEM.ChainSelect.superclass.constructor.call(this, config);
    },

    getValue: function () {
        var pData = {};

        this.items.each(function(i){
            if(!i.level || i.xtype !== "combo" || i.disabled){
                return;
            }

            pData[i.level] = i.getValue();
        });

        return $.isEmptyObject(pData) ? "" : JSON.stringify(pData);
    },

    setValue: function (value) {
        var pData = JSON.parse(value);
        var levels = "", keywords = "", x = "", combo;

        for(x in pData){
            if(pData.hasOwnProperty(x)){
                levels = levels + x + ",";
                keywords = keywords + pData[x] + ",";
            }
        }

        levels = levels.substring(0, levels.lastIndexOf(","));
        keywords = keywords.substring(0, keywords.lastIndexOf(","));

        var lData = this.getDropDownData({ levels : levels, keywords: keywords });

        for(x in lData){
            if(lData.hasOwnProperty(x)){
                combo = this.findBy(function(comp){
                    return comp["level"] == x;
                }, this);

                combo[0].store.loadData(lData[x], false);
            }
        }

        this.items.each(function(i){
            if(!i.level || i.xtype !== "combo"){
                return;
            }

            if(pData[i.level]){
                i.setValue(pData[i.level]);
            }else{
                i.setDisabled(true);
            }
        });
    },

    validate: function(){
        return true;
    },

    getName: function(){
        return this.name;
    },

    getDropDownData: function(params){
        if(!params){
            params = { level : 1, keyword: "" }
        }

        var lData;

        $.ajax({
            url: '/bin/mycomponents/chainselect/dropdowndata',
            dataType: "json",
            type: 'GET',
            async: false,
            data: params,
            success: function(data){
                lData = data;
            }
        });

        return lData;
    },

    initComponent: function () {
        ExperienceAEM.ChainSelect.superclass.initComponent.call(this);

        var lData = this.getDropDownData();

        if(!lData){
            CQ.Ext.Msg.alert("Error","Error getting levels data or no data available");
            return;
        }

        for(var x = 1; x <= this.levels; x++){
            this.add(new CQ.Ext.form.ComboBox({
                store: new CQ.Ext.data.ArrayStore({
                    fields: ["id", "text"],
                    data: lData[x]
                }),
                mode: "local",
                triggerAction: "all",
                isFormField: false,
                level: x,
                fieldLabel: "Level " + x,
                valueField: 'id',
                displayField: 'text',
                emptyText: 'Select level ' + x,
                style: "margin-bottom:20px",
                xtype: 'combo',
                listeners:{
                    scope: this,
                    select: function(combo){
                        var keyword = combo.getValue();

                        var lowCombo = this.findBy(function(comp){
                            return comp["level"] == (combo.level + 1);
                        }, this);

                        if(!lowCombo || (lowCombo.length == 0)){
                            return;
                        }

                        lData = this.getDropDownData({ level : combo.level + 1, keyword: keyword });
                        var level = combo.level + 1;

                        do{
                            lowCombo = this.findBy(function(comp){
                                return comp["level"] == level;
                            }, this);

                            if(!lowCombo || (lowCombo.length == 0)){
                                break;
                            }

                            lowCombo = lowCombo[0];
                            lowCombo.clearValue();

                            if(lData[lowCombo.level]){
                                lowCombo.setDisabled(false);
                                lowCombo.store.loadData(lData[lowCombo.level], false);
                            }else{
                                lowCombo.setDisabled(true);
                            }

                            level = lowCombo.level + 1;
                        }while(true);
                    }
                }
            }));
        }

        this.panelValue = new CQ.Ext.form.Hidden({
            name: this.name
        });

        this.add(this.panelValue);

        var dialog = this.findParentByType('dialog');

        dialog.on('beforesubmit', function(){
            var value = this.getValue();

            if(value){
                this.panelValue.setValue(value);
            }
        },this);

        this.panelValue.on('loadcontent', function(){
            this.setValue(this.panelValue.getValue());
        },this);
    }
});

CQ.Ext.reg("chainselect", ExperienceAEM.ChainSelect);


7) Here is a sample dialog xml with chainselect widget configured ( with 3 level combos )

<?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:Dialog"
    helpPath="en/cq/current/wcm/default_components.html#Text"
    title="Text"
    xtype="tabpanel">
    <items jcr:primaryType="cq:WidgetCollection">
        <tab1
            jcr:primaryType="cq:Widget"
            anchor="100%"
            title="Text"
            xtype="panel">
            <items jcr:primaryType="cq:WidgetCollection">
                <chainselect
                    jcr:primaryType="cq:Widget"
                    border="false"
                    layout="form"
                    levels="3"
                    name="./chainselect"
                    padding="10px"
                    xtype="chainselect"/>
            </items>
        </tab1>
    </items>
</jcr:root>


2 comments: