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.
No comments:
Post a Comment
If you have any doubts or questions, please let us know.