May 26, 2020
Estimated Post Reading Time ~

Create Custom Xtypes For Pathfield BrowseDialog in AEM

Sometimes, we need to design custom xtypes for pathfield BrowseDialog on a regular basis. This is because the default xtypes provided by AEM doesn’t fulfil our requirements.

Problem with the Default xtype in AEM:
While working with xtype pathfield, I stumbled upon a use case/problem. I wanted a pathfield so that I can choose the product under /etc/commerce/products/accunity/en_us/products hierarchy by a productName property.

Note: Generally, pathfield can show the nodes by their name or jcr:title.

Steps I followed to Solve This xtype Error:
I created a widget of xtype:pathfield
If I opened my  dialog, I could select any values under /content
So I needed to set rootPath in the widget
rootPath:/etc/commerce/products/accunity/en_us/products
The nodes under products are of type nt:unstructured. By default, the pathfield doesn’t allow these types of nodes in the tree hierarchy.
So added a property predicate: nosystem

Now the pathfield looked like this:
how the pathfield looked like after creating a widget of xtype:pathfield
But still, it is a very tedious task for the author to select a particular product. I wanted the pathfield to show the productName in place of node-name. So I decided to write a custom xtype.

But How did I Write the Custom xtype?
The first question here is how this tree structure shows up here:

Product PathField in AEM xtype

So while debugging my dialog, I found, it calls currentPath.ext.json and shows the “name” property of  JSON in the tree hierarchy.

So the next step for me was to change this servlet.

import org.apache.felix.scr.annotations.Component;
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.resource.Resource;
import org.apache.sling.api.resource.ValueMap;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.apache.sling.commons.json.JSONArray;
import org.apache.sling.commons.json.JSONException;
import org.apache.sling.commons.json.JSONObject;

import java.io.IOException;
import java.util.Iterator;

@Component
@SlingServlet(generateComponent = false, resourceTypes = "sling/servlet/default", selectors = {"path"}, extensions = {"json"})

public class MyServlet extends SlingSafeMethodsServlet {
   protected void doGet(SlingHttpServletRequest request,SlingHttpServletResponse response)throws IOException
   {
       if (request.getRequestPathInfo().getSelectors()[0].equals("path")) {
           String resourcePath = request.getRequestPathInfo().getResourcePath();
           Resource resource = request.getResourceResolver().getResource(resourcePath);
           if (resource != null) {
               Iterator<Resource> iter = resource.listChildren();
               JSONArray jsonArray = new JSONArray();
               JSONObject jsonObject = null;
               while (iter.hasNext()) {
                   Resource childResource = iter.next();
                   if (!childResource.getName().equals("image")) {
                       ValueMap valueMap = childResource.adaptTo(ValueMap.class);
                       jsonObject = new JSONObject();
                       try {
                           jsonObject.put("name", childResource.getName());
                           if (valueMap.containsKey("productName"))
                               jsonObject.put("text", valueMap.get("productName", ""));
                           else if (valueMap.containsKey("jcr:title"))
                               jsonObject.put("text", valueMap.get("jcr:title", ""));
                           else
                               jsonObject.put("text", childResource.getName());
                           jsonObject.put("type", valueMap.get("jcr:primaryType", ""));
                           if (valueMap.get("jcr:primaryType").equals("sling:folder"))
                               jsonObject.put("cls", "folder");
                           else
                               jsonObject.put("cls", "file");
                           jsonArray.put(jsonObject);
                       } catch (JSONException e) {
                           e.printStackTrace();
                       }
                   }
               }
               response.getWriter().print(jsonArray);
           }
       }

   }
}

The next question was from where this servlet is getting called.

The answer is browserDialog widget. Inside the pathfield widget, it is calling browserDialog to show this tree structure.

Note: Go to browseDialog.js

Change this part:
the tree structure in browsedialog in AEM

Here is the updated browseDialog.js

CQ.CustomBrowseDialog = CQ.Ext.extend(CQ.Dialog, {
    /**
     * The browse dialog's tree panel.
     * @private
     * @type CQ.Ext.tree.TreePanel
     */
    treePanel: null,

    /**
     * The browse dialog's browse field.
     * @private
     * @type CQ.form.BrowseField
     */
    browseField: null,

    initComponent: function(){
       CQ.CustomBrowseDialog.superclass.initComponent.call(this);
    },

    /**
     * Selects the specified path in the tree.
     * @param {String} path The path to select
     */
    loadContent: function(path) {
        if (typeof path == "string") {
            this.path = path;
            this.treePanel.selectPath(path,"name");
            if(this.parBrowse){
                // reload paragraph store
                this.paraProxy.api["read"].url = CQ.HTTP.externalize(path, true) + ".paragraphs.json";
                this.paraStore.reload();
            }
        }
    },

    /**
     * Returns the path of the selected tree node (or an empty string if no
     * tree node has been selected yet).
     * @return {String} The path
     */
    getSelectedPath: function() {
        try {
            return this.treePanel.getSelectionModel().getSelectedNode().getPath();
        } catch (e) {
            return "";
        }
    },

    /**
     * Returns the anchor of the selected paragraph (or an empty string if
     * no paragraph has been selected yet).
     * @return {String} The anchor
     */
    getSelectedAnchor: function() {
        try {
            var anchorID = this.data.getSelectedRecords()[0].get("path");
            anchorID = anchorID.substring(anchorID.indexOf("jcr:content")
                    + "jcr:content".length + 1);
            return anchorID.replace(/\//g, "_").replace(/:/g, "_");
        } catch (e) {
            return "";
        }
    },

    constructor: function(config){

        var treeRootConfig = CQ.Util.applyDefaults(config.treeRoot, {
            "name": "content",
            "text": CQ.I18n.getMessage("Site"),
            "draggable": false,
            "singleClickExpand": true,
            "expanded":true
        });

        var treeLoaderConfig = CQ.Util.applyDefaults(config.treeLoader, {
            "dataUrl": CQ.HTTP.externalize("/content.path.json"),
            "requestMethod":"GET",
            "baseParams": {
                "predicate": "hierarchy",
                "_charset_": "utf-8"
            },
            "baseAttrs": {
                "singleClickExpand":true
            },
            "listeners": {
                "beforeload": function(loader, node){
                    this.dataUrl = node.getPath() + ".path.json";
                }
            }
        });

        this.treePanel = new CQ.Ext.tree.TreePanel({
            "region":"west",
            "lines": CQ.themes.BrowseDialog.TREE_LINES,
            "bodyBorder": CQ.themes.BrowseDialog.TREE_BORDER,
            "bodyStyle": CQ.themes.BrowseDialog.TREE_STYLE,
            "height": "100%",
            "width": 200,
            "autoScroll": true,
            "containerScroll": true,
            "root": new CQ.Ext.tree.AsyncTreeNode(treeRootConfig),
            "loader": new CQ.Ext.tree.TreeLoader(treeLoaderConfig),
            "defaults": {
                "draggable": false
            }
        });

        var width = CQ.themes.BrowseDialog.WIDTH;
        var items = this.treePanel;

        if (config.parBrowse) {
            this.treePanel.on("click", this.onSelectPage.createDelegate(this));

            // Paragraph store
            var reader = new CQ.Ext.data.JsonReader({
                "id":            "path",
                "root":          "paragraphs",
                "totalProperty": "count",
                "fields":        [ "path", "html" ]
            });
            this.paraProxy = new CQ.Ext.data.HttpProxy({
                "url": "/"
            });
            this.paraStore = new CQ.Ext.data.Store({
                "proxy":    this.paraProxy,
                "reader":   reader,
                "autoLoad": false
            });

            // Paragraph template
            var paraTemplate = new CQ.Ext.XTemplate(
                '<tpl for=".">',
                    '<div class="cq-paragraphreference-paragraph">{html}</div>',
                '</tpl>'
            );

            // Paragraph view
            this.data = new CQ.Ext.DataView({
                "id": "cq-paragraphreference-data",
                "region": "center",
                "store": this.paraStore,
                "tpl": paraTemplate,
                "itemSelector": "div.cq-paragraphreference-paragraph",
                "selectedClass": "cq-paragraphreference-selected",
                "singleSelect": true,
                "style": { "overflow": "auto" }
            });

            // init dialog width and fields
            width = 550;
            items = new CQ.Ext.Panel({
                "border":false,
                "layout": "border",
                "items": [ this.treePanel, this.data ]
            });
        }

        CQ.Util.applyDefaults(config, {
            "title": CQ.I18n.getMessage("Select Path"),
            "closable": true,
            "width": width,
            "height": CQ.themes.BrowseDialog.HEIGHT,
            "minWidth": CQ.themes.BrowseDialog.MIN_WIDTH,
            "minHeight": CQ.themes.BrowseDialog.MIN_HEIGHT,
            "resizable": CQ.themes.BrowseDialog.RESIZABLE,
            "resizeHandles": CQ.themes.BrowseDialog.RESIZE_HANDLES,
            "autoHeight": false,
            "autoWidth": false,
            "cls":"cq-browsedialog",
            "ok": function() { this.hide(); },
            "buttons": CQ.Dialog.OKCANCEL,
            "items": items
        });
       CQ.CustomBrowseDialog.superclass.constructor.call(this, config);
    },

    /**
     * @private
     */
    onSelectPage: function(node, event) {
        this.paraProxy.api["read"].url = CQ.HTTP.externalize(node.getPath() + ".paragraphs.json", true);
        this.paraStore.reload();
    }
});

CQ.Ext.reg('recipebrowsedialog',CQ.CustomBrowseDialog);
We can’t make this change in the /libs section. So, I made my own xtype as productPathfield and add a custom browseDialog in pathfield.js with this modification.

Note: xtype pathfield doesn’t fulfill my requirements so needed to change it with productPathfield.

CQ.form.CustomPathField = CQ.Ext.extend(CQ.Ext.form.ComboBox, {

    /**
     * Remembers the last value when the last key up happened.
     * @type String
     * @private
     */
    lastValue: null,

    /**
     * The ID of the delayed search interval.
     * @type Number
     * @private
     */
    searchIntervalId: 0,

    /**
     * The panel holding the link-browser.
     * @type CQ.BrowseDialog
     * @private
     */
    browseDialog: null,

    /**
     * Returns the anchor of the selected paragraph (or an empty string if
     * no paragraph has been selected yet).
     * @return {String} The anchor
     */
    getParagraphAnchor: function() {
        return this.browseDialog.getSelectedAnchor();
    },

    /**
     * Checks if the current path is quoted. If yes the new value is decorated
     * with quotes as well.
     * @private
     */
    adjustNewValue: function(currentValue, newValue) {
        if (/^path:"/.test(currentValue)) {
            // current value starts with quotes: decorate with quotes
            // (add final quotes even if they do not exist yet - otherwise
            // the triggered search would fail ('path:"/content')
            newValue = '"' + newValue + '"';
        }
        return newValue;
    },

    /**
     * Executed on key up in the control.
     * - Checks if its value matches a path. If yes, request .pages.json
     * @private
     */
    keyup: function(comp, evt) {
        var currentValue = this.getRawValue();

        var key = evt.getKey();
        if (key == 13) {
            // [enter] hit
            this.fireEvent("search", this, currentValue);
        }

        if (currentValue == this.lastValue) {
            // value did not change (key was arrows, ctrl etc.)
            return;
        }
        this.lastValue = currentValue;

        var path = currentValue;

        if (/^\//.test(path) && /\/$/.test(path)) {
            // path starts with a slash: ignore non-absolute path (#29745)
            // path ends with a slash: request path.pages.json
            if (path == "/") {
                path = this.rootPath ? this.rootPath : "/";
            }
            else {
                // remove final slash:
                path = path.replace(/\/$/, "");
            }
            this.loadStore(CQ.shared.HTTP.encodePath(path));
        }
        else if (this.searchDelay) {
            window.clearTimeout(this.searchIntervalId);
            var pc = this;
            this.searchIntervalId = window.setTimeout(function() {
                pc.fireEvent("search", pc, currentValue);
            }, this.searchDelay);
        }

    },

    /**
     * Reloads the autocompletion store with a new URL.
     * @private
     */
    loadStore: function(path) {
        this.store.proxy.api["read"].url = path + ".pages.json";
        this.store.reload();
    },

    /**
     * The trigger action of the TriggerField, creates a new BrowseDialog
     * if it has not been created before, and shows it.
     * @private
     */
    onTriggerClick : function() {
        if (this.disabled) {
            return;
        }
        // lazy creation of browse dialog
        if (this.browseDialog == null || this.modeless) {
            function okHandler() {
                var path = this.getSelectedPath();
                var anchor = this.parBrowse ? this.getSelectedAnchor() : null;

                var value;
                if (anchor) {
                    value = CQ.Util.patchText(this.pathField.parLinkPattern, [path, anchor]);
                } else {
                    value = CQ.Util.patchText(this.pathField.linkPattern, path);
                }
                if (this.pathField.suffix) {
                    value += this.pathField.suffix;
                }

                this.pathField.setValue(value);

                this.pathField.fireEvent("dialogselect", this.pathField, path, anchor);
                this.hide();
            }

            var browseDialogConfig = CQ.Util.applyDefaults(this.browseDialogCfg, {
                ok: okHandler,
                // pass this to the BrowseDialog to make in configurable from 'outside'
                parBrowse: this.parBrowse,
                treeRoot: this.treeRoot,
                treeLoader: this.treeLoader,
                listeners: {
                    hide: function() {
                        if (this.pathField) {
                            this.pathField.fireEvent("dialogclose");
                        }
                    }
                },
                loadAndShowPath: function(path) {
                    this.path = path;
                    // if the root node is the real root, we need an additional slash
                    // at the begining for selectPath() to work properly
                    if (this.pathField.rootPath == "" || this.pathField.rootPath == "/") {
                        path = "/" + path;
                    }

                    var browseDialog = this;
                    var treePanel = this.treePanel;

                    // what to do when selectPath worked
                    function successHandler(node) {
                        // ensureVisible fails on root, ie. getParentNode() == null
                        if (node.parentNode) {
                            node.ensureVisible();
                        }
                        if (browseDialog.parBrowse) {
                            browseDialog.onSelectPage(node);
                        }
                    }

                    // string split helper function
                    function substringBeforeLast(str, delim) {
                        var pos = str.lastIndexOf(delim);
                        if (pos >= 0) {
                            return str.substring(0, pos);
                        } else {
                            return str;
                        }
                    }

                    // try to handle links created by linkPattern/parLinkPattern,
                    // such as "/content/foo/bar.html#par_sys"; needs to try various
                    // cut-offs until selectPath works (eg. /content/foo/bar)
                    // 1) try full link (path)
                    treePanel.selectPath(path, null, function(success, node) {
                        if (success && node) {
                            successHandler(node);
                        } else {
                            // 2) try and split typical anchor from (par)linkPattern
                            path = substringBeforeLast(path, "#");

                            treePanel.selectPath(path, null, function(success, node) {
                                if (success && node) {
                                    successHandler(node);
                                } else {
                                    // 3) try and split typical extension from (par)linkPattern
                                    path = substringBeforeLast(path, ".");

                                    treePanel.selectPath(path, null, function(success, node) {
                                        if (success && node) {
                                            successHandler(node);
                                        }
                                    });
                                }
                            });
                        }
                    });
                },
                pathField: this
            });

            // fix dialog width for par browse to include 3 cols of pars
            if (this.parBrowse) {
                browseDialogConfig.width = 570;
            }

            // build the dialog and load its contents
            this.browseDialog = new CQ.CustomBrowseDialog(browseDialogConfig);
        }

        this.browseDialog.loadAndShowPath(this.getValue());

        this.browseDialog.show();
        this.fireEvent("dialogopen");
    },

    constructor : function(config){
        // set default values
        // done here, because it is already used in below applyDefaults
        if (typeof config.rootTitle === "undefined") {
            config.rootTitle = config.rootPath || CQ.I18n.getMessage("Websites");
        }
        if (typeof config.rootPath === "undefined") {
            config.rootPath = "/content";
        }
        var rootName = config.rootPath;
        // the root path must not include a leading slash for the root tree node
        // (it's added automatically in CQ.Ext.data.Node.getPath())
        if (rootName.charAt(0) === "/") {
            rootName = rootName.substring(1);
        }
        if (typeof config.predicate === "undefined") {
            config.predicate = "siteadmin";
        }
        if (typeof config.showTitlesInTree === "undefined") {
            config.showTitlesInTree = true;
        }

        var pathField = "path";
        if (config.escapeAmp) {
            pathField = "escapedPath";
            delete config.escapeAmp;
        }

        CQ.Util.applyDefaults(config, {
            linkPattern: config.parBrowse ? "{0}.html" : "{0}",
            parLinkPattern: "{0}.html#{1}",

            tpl: new CQ.Ext.XTemplate(
                '<tpl for=".">',
                    '<div ext:qtip="{tooltip}" class="x-combo-list-item">',
                        '<span class="cq-pathfield-completion-list-name">{label}</span>',
                        '<span class="cq-pathfield-completion-list-title">{title}</span>',
                    '</div>',
                '</tpl>'),
            displayField: pathField,
            typeAhead: true,
            searchDelay: 200,
            suffix:"",
            mode: 'local',
            selectOnFocus:true,
            enableKeyEvents: true,
            validationEvent: false,
            validateOnBlur: false,
            // show a search icon
            triggerClass: "x-form-search-trigger",
            treeRoot: {
                name: rootName,
                // label for the root
                text: config.rootTitle
            },
            treeLoader: {
                dataUrl: CQ.shared.HTTP.getXhrHookedURL(CQ.Util.externalize(config.rootPath + ".ext.json")),
                baseParams: {
                    predicate: config.predicate,
                    "_charset_": "utf-8"
                },
                // overwriting method to be able to intercept node labeling
                createNode: function(attr) {
                    if (!config.showTitlesInTree) {
                        // no labled resources, use plain node name for tree nodes
                        attr.text = attr.name;
                    }
                    return CQ.Ext.tree.TreeLoader.prototype.createNode.call(this, attr);
                },
                // overwriting method to fix handling of array params
                // (needed for config.predicate string array case)
                getParams: function(node) {
                    var params = this.baseParams;
                    params.node = node.id;
                    return CQ.Ext.urlEncode(params);
                },
                listeners: {
                    beforeLoad: function(loader, node) {
                        this.dataUrl = node.getPath() + ".ext.json";
                    }
                }
            }
        });

        // store for autocompletion while typing
        if (!(config.store instanceof CQ.Ext.data.Store)) {
            var storeConfig = CQ.Util.applyDefaults(config.store, {
                // URL for proxy is set dynamically based on current path in loadStore()
                proxy: new CQ.Ext.data.HttpProxy({
                    url: "/",
                    method:"GET"
                }),
                baseParams: {
                    predicate: config.predicate
                },
                "reader": new CQ.Ext.data.JsonReader(
                    {
                        "totalProperty": "results",
                        "root": "pages",
                        "id": "path"
                    },
                    CQ.Ext.data.Record.create([
                        {
                            "name": "label",
                            "convert": function(v, rec) {return CQ.shared.XSS.getXSSValue(rec.label);}
                        },
                        {
                            "name": "title",
                            "mapping": CQ.shared.XSS.getXSSPropertyName("title")
                        },
                        {
                            "name": pathField
                        },
                        {
                            "name": "tooltip",
                            // have to encode this twice because the template decodes the value before 
                            // injecting it into the tooltip div
                            "convert": function(v, rec) {return _g.Util.htmlEncode(_g.Util.htmlEncode(rec.path));}
                        }
                    ])
                )
            });
            config.store = new CQ.Ext.data.Store(storeConfig);
        }
        this.store = config.store;

        CQ.form.CustomPathField.superclass.constructor.call(this, config);
    },

    initComponent : function(){
        CQ.form.CustomPathField.superclass.initComponent.call(this);

        this.addListener("keyup", this.keyup, this);

        this.addEvents(
            /**
             * @event search
             * Fires when the enter key is hit or after the user stopped typing.
             * The period between the last key press and the firing of the event
             * is specified in {@link #searchDelay}.
             * @param {CQ.form.CustomPathField} this
             * @param {String} value The current value of the field
             */
            'search',
            /**
             * @event dialogopen
             * Fires when the browse dialog is opened.
             * @param {CQ.form.CustomPathField} this
             */
            "dialogopen",
            /**
             * @event dialogselect
             * Fires when a new value is selected in the browse dialog.
             * @param {CQ.form.CustomPathField} this
             * @param {String} path The path selected in the tree of the browse dialog
             * @param {String} anchor The paragraph selected in the browse dialog (or null)
             */
            "dialogselect",
            /**
             * @event dialogclose
             * Fires when the browse dialog is closed.
             * @param {CQ.form.CustomPathField} this
             */
            "dialogclose"
        );
        
        // register component as drop target
        CQ.WCM.registerDropTargetComponent(this);
    },
    
    getDropTargets : function() {
        var pathFieldComponent = this;
        var target = new CQ.wcm.EditBase.DropTarget(this.el, {
            "ddAccept": "*/*",
            "notifyDrop": function(dragObject, evt, data) {
                if (dragObject && dragObject.clearAnimations) {
                    dragObject.clearAnimations(this);
                }
                if (data && data.records && data.records[0]) {
                    var pathInfo = data.records[0].get("path");
                    if (pathInfo) {
                        pathFieldComponent.setValue(pathInfo);
                        return true;
                    }
                }
                return false;
            }
        });
        target.groups["media"] = true;
        target.groups["s7media"] = true;
        target.groups["page"] = true;
        return [target];
    }
});

CQ.Ext.reg("productPathfield", CQ.form.CustomPathField);
After all the changes, we can see the desired results as follows.



By aem4beginner

No comments:

Post a Comment

If you have any doubts or questions, please let us know.