Blogs

Custom Xtype For Pathfield BrowseDialog
Create Custom Xtype For Pathfield BrowseDialog in AEM

 

Sometimes the default xtypes provided by AEM doesn’t fulfill our requirements. So we need to design our custom xtypes for pathfield BrowseDialog on a regular basis.

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 its name or jcr:title.

path field

Steps I followed to Solve This xtype Error:
  1. I created a widget of xtype:pathfield
  2. If I opened my  dialog, I could select any values under /content
  3. So I needed to set rootPath in the widget
    rootPath:/etc/commerce/products/accunity/en_us/products
  4. The nodes under products are of type nt:unstructured. By default, pathfield doesn’t allow this types of nodes in the tree hierarchy.
  5. 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 pathfield widget, it is calling browserDialog to show this tree structure.

Note: Go to browseDialog.js

Change this part:

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.

product pathfield in AEM

Please leave your precious comments on what you think about this approach. Happy to learn better solution for the same problem!

Share it:

Himani Hasiza

September 28, 2016

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Comment

Related Posts