/*
* Copyright 2015 Anyware Services
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This class provides a TreePanel for contents.
*/
Ext.define('Ametys.plugins.contentstree.ContentsTreePanel', {
extend: 'Ext.tree.Panel',
/**
* @cfg {String} treeId (required) The id of tree configuration
*/
/**
* @cfg {String} [serverRole="org.ametys.plugins.contentstree.ContentsTreeHelper"] The role of server component which handle tree
*/
/**
* @cfg {String} [methodArguments=['contentId', 'tree']] The arguments name when calling the server
*/
/**
* @cfg {String} [filterMethodName="filterChildrenContentByRegExp"] The server method name of filter tree
*/
/**
* @cfg {String} [model=Ametys.plugins.contentstree.ContentsTreePanel.ContentsTreeModel] The class name of the model for the store. Default to {@link Ametys.plugins.contentstree.ContentsTreePanel.ContentsTreeModel}
*/
/**
* @cfg {Boolean} [displayToolbar=true] Set to true to display the toolbar for some help tools.
*/
/**
* @cfg {Number} [toolbarPosition=0] Set to toolbar position into docked items. Default to 0.
*/
/**
* @cfg {Boolean} [checkMode=false] Set to true to select contents by check boxes.
*/
/**
* @cfg {Boolean} [checkRoot=true] If {@link #cfg-checkMode} is true, set this to false if you do not want a checkbox for the root node.
*/
constructor: function(config)
{
config.checkMode = config.checkMode || false;
if (!config.model && config.checkMode)
{
config.model = config.checkRoot == false ? 'Ametys.plugins.contentstree.ContentsTreePanel.CheckableExceptRootContentsTreeModel' : 'Ametys.plugins.contentstree.ContentsTreePanel.CheckableContentsTreeModel';
}
else if (!config.model)
{
config.model = 'Ametys.plugins.contentstree.ContentsTreePanel.ContentsTreeModel';
}
config.serverRole = config.serverRole || 'org.ametys.plugins.contentstree.ContentsTreeHelper';
config.methodArguments = config.methodArguments || ['contentId', 'path', 'tree'];
config.filterMethodName = config.filterMethodName || 'filterChildrenContentByRegExp';
config.store = Ext.create('Ext.data.TreeStore', {
model : config.model,
autoLoad : false,
proxy : {
type : 'ametys',
role: config.serverRole,
methodName: 'getChildrenContent',
methodArguments: config.methodArguments,
reader : {
type : 'json'
},
extraParams: {
tree: config.treeId
}
},
listeners: {
'beforeload': function(store, operation)
{
if (operation.node.get('contentId'))
{
operation.setParams(Ext.apply(operation.getParams() || {}, {
contentId: operation.node.get('contentId'),
path: operation.node.getPath('contentId', ';').substring(1).split(';')
}));
}
else
{
// FIXME Sometimes the contentId is not set and it result to an exception
try
{
throw new Error("This should not happen");
}
catch(e)
{
console.error(e);
}
return false;
}
}
}
});
config.displayField = config.displayField || 'title';
this._counter = {};
config.dockedItems = config.dockedItems || [];
config.displayToolbar = config.displayToolbar !== false;
if (config.displayToolbar)
{
// Get toolbar bar configuration
var toolbarCfg = this._getToolBarConfig(config);
if (!Ext.isEmpty(toolbarCfg))
{
Ext.Array.insert(config.dockedItems, config.toolbarPosition || 0, [toolbarCfg]);
config.dockedItems.push(this._getNoResultPanel());
}
}
this.callParent(arguments);
},
/**
* @protected
* Get configuration for toolbar
* @param {Object} the tree configuration object
* @return {Object} the toolbar configuration object
*/
_getToolBarConfig: function (config)
{
return {
dock: 'top',
xtype: 'toolbar',
layout: {
type: 'hbox',
align: 'stretch'
},
defaultType: 'button',
items: [{
// Filter input
xtype: 'textfield',
cls: 'ametys',
flex: 1,
maxWidth: 300,
itemId: 'search-filter-input',
emptyText: "{{i18n PLUGINS_CONTENTSTREE_TREE_FILTER}}",
minLength: 3,
minLengthText: "{{i18n PLUGINS_CONTENTSTREE_TREE_FILTER_INVALID}}",
msgTarget: 'qtip',
listeners: {change: Ext.Function.createBuffered(this._searchFilter, 500, this)},
style: {
marginRight: '0px'
}
},
{
// Clear filter
tooltip: "{{i18n PLUGINS_CONTENTSTREE_TREE_CLEAR_FILTER}}",
handler: Ext.bind (this.clearSearchFilter, this),
iconCls: 'a-btn-glyph ametysicon-eraser11 size-16',
cls: 'a-btn-light'
},
{
xtype: 'tbspacer',
flex: 0.0001
},
{
// Collapse all
tooltip: "{{i18n PLUGINS_CONTENTSTREE_TREE_COLLAPSE_ALL}}",
handler: Ext.bind (this.collapseNode, this, [], false),
iconCls: 'a-btn-glyph ametysicon-minus-sign4 size-16',
cls: 'a-btn-light'
},
{
// Refresh node
tooltip: "{{i18n PLUGINS_CONTENTSTREE_TREE_REFRESH_NODE}}",
handler: Ext.bind (this.refreshNode, this, [], false),
iconCls: 'a-btn-glyph ametysicon-arrow123 size-16',
cls: 'a-btn-light'
}
]
};
},
/**
* @private
* Get the 'no result' button configuration. This button is shown when filter matches no result.
* @return {Object} the button configuration
*/
_getNoResultPanel: function ()
{
return {
dock: 'top',
xtype: 'button',
hidden: true,
itemId: 'no-result',
ui: 'tool-hintmessage',
text: "{{i18n PLUGINS_CONTENTSTREE_TREE_FILTER_NO_MATCH}}" + "{{i18n PLUGINS_CONTENTSTREE_TREE_FILTER_NO_MATCH_ACTION}}",
scope: this,
handler: this.clearSearchFilter
};
},
/**
* @private
* Show or hide the 'no result' button.
* @param {Boolean} show true to show the button, false to hide it.
*/
_showHideNoResultPanel: function (show)
{
if (this.down("button[itemId='no-result']"))
{
this.down("button[itemId='no-result']").setVisible(show);
}
},
/**
* Updates the node UI (icon, text, ...)
* @param {Ext.data.NodeInterface} node the node
*/
updateNodeUI: function(node)
{
// Nothing to do
},
/**
* This listener is called on 'keyup' event on filter input field.
* Filters the tree by text input.
* @param {Ext.form.Field} field The field
* @private
*/
_searchFilter: function (field)
{
var value = new String(field.getValue()).trim();
this._filterField = field;
if (this._filterValue == value)
{
// Do nothing
return;
}
this._filterValue = value;
if (value.length > 2)
{
var rootNode = this.getRootNode();
this._getFilteredContents (value, rootNode);
}
else
{
this._showHideNoResultPanel(false);
this.clearFilter();
}
},
/**
* Get the tags the name matches the given value
* @param {String} value The value to match
* @param {Ext.data.Model} node The node where starting search
* @param {Boolean} [childNodesOnly] set to 'true' to filter the child nodes only.
* @private
*/
_getFilteredContents: function (value, node, childNodesOnly)
{
Ametys.data.ServerComm.callMethod({
role: this.getInitialConfig('serverRole'),
methodName: this.getInitialConfig('filterMethodName'),
parameters: [node.get('contentId'), this.getInitialConfig('treeId'), value],
errorMessage: "{{i18n PLUGINS_CONTENTSTREE_TREE_SEARCH_ERROR}}",
callback: {
handler: this._filterContentsCb,
scope: this,
arguments: {
node: node,
childNodesOnly: childNodesOnly
}
}
});
},
/**
* @private
* Callback function after searching pages
* @param {Object} paths The paths of matching pages
* @param {Object[]} args The callback arguments
*/
_filterContentsCb: function(paths, args)
{
var hasResult = false;
var node = args.node || this.getRootNode();
if (!paths)
{
return;
}
if (paths.length == 0)
{
if (args.childNodesOnly)
{
// There is no child nodes matching but some other nodes are matching.
hasResult = true;
for (var i=0; i < node.childNodes.length; i++)
{
this.filterBy (function () {return false}, node.childNodes[i]);
}
}
else
{
this.filterBy (function () {return false}, node);
}
}
else
{
hasResult = true;
var childs = node.childNodes;
this._expandAndFilter (paths, this.getRootNode(), node);
}
if (!hasResult)
{
this._showHideNoResultPanel(true);
if (this._filterField)
{
Ext.defer (this._filterField.markInvalid, 100, this._filterField, ["{{i18n PLUGINS_CONTENTSTREE_TREE_FILTER_NO_MATCH}}"]);
}
}
else
{
this._showHideNoResultPanel(false);
if (this._filterField)
{
this._filterField.clearInvalid();
}
}
},
/**
* Expand the tree to the given paths.
* @param {Object[]} paths The paths to expand
* @param {Ext.data.Model} rootNode The concerned root node
* @param {Ext.data.Model} node The node from which apply filter
* @private
*/
_expandAndFilter: function(paths, rootNode, node)
{
node = node || rootNode;
this._counter[rootNode.get('contentId')] = paths.length;
for (var i=0; i < paths.length; i++)
{
var path = paths[i].substr(0, paths[i].lastIndexOf(";"));
this.expandPath (';' + path, 'contentId', ';', Ext.bind (this._filterPaths, this, [paths, rootNode, node], false));
}
},
/**
* Filter nodes by path once the last expand has been processed
* @param {String[]} paths The path to filter by
* @param {Ext.data.Model} rootNode The concerned root node
* @param {Ext.data.Model} node The node from which apply filter
* @private
*/
_filterPaths: function (paths, rootNode, node)
{
// only execute the filterBy after the last expandPath()
if (--this._counter[rootNode.get('contentId')] == 0)
{
var filterFn = Ext.bind (this._filterByPath, this, [paths, rootNode], true);
// FIXME Ensure that expand is complete by deferring the filterBy function ...
Ext.defer(this.filterBy, 50, this, [filterFn, node]);
}
},
/**
* Returns true if the node path is a part of given paths
* @param {Ext.data.Model} node The node to test
* @param {String[]} paths The paths
* @param {Ext.data.Model} rootNode The root node to build the complete paths
* @private
*/
_filterByPath: function (node, paths, rootNode)
{
var currentPath = node.getPath('contentId', ';');
for (var i=0; i < paths.length; i++)
{
var path = ';' + paths[i] + ';';
if (path.indexOf(currentPath + ';') == 0)
{
return true;
}
}
return false;
},
/**
* Filters by a function. The specified function will be called for each Record in this Store.
* If the function returns true the Record is included, otherwise it is filtered out.
* @param {Function} filterFn A function to be called.
*/
filterBy: function (filterFn)
{
this.clearFilter();
this.getStore().filterBy(filterFn);
},
/**
* Clear the filter search if exists
*/
clearSearchFilter: function()
{
this.clearFilter();
if (this._filterField)
{
this._filterField.reset();
}
this._filterValue = null;
this._showHideNoResultPanel(false);
var selection = this.getSelectionModel().getSelection()[0];
if (selection)
{
this.ensureVisible(selection.getPath('name'), {field: 'name'});
}
},
/**
* Clear all filters
*/
clearFilter: function (node)
{
this.getStore().clearFilter();
var selection = this.getSelectionModel().getSelection()[0];
if (selection)
{
this.ensureVisible(selection.getPath());
}
},
/**
* Collapse recursively the children of the node, then select the collapsed node.
* @param {Ext.data.NodeInterface} [node] The node the collapse. Can be null to collapse the whole tree.
*/
collapseNode: function (node)
{
node = node || this.getRootNode();
node.collapseChildren(true);
this.getSelectionModel().select(node);
},
/**
* This function reload the given node
*/
refreshNode: function ()
{
var selection = this.getSelectionModel().getSelection();
node = selection.length > 0 ? selection[0] : null;
// Workaround - Refresh selection in case node is not existing anymore (deleted by another user for example).
this.getSelectionModel().deselect(node, true);
this.getSelectionModel().select(node);
if (node != null)
{
let me = this;
this._reloadNodes([node], function() {
if (me._filterValue && me._filterValue.length > 2)
{
// Filter child nodes only
me._getFilteredContents (me._filterValue, node, true);
}
else
{
Ext.defer(me.expandNode, 200, me, [node]);
}
});
}
},
/**
* Compoute the path of the node in the tree
* @param {Ext.data.NodeInterface} node The node to compute
*/
getNodePath: function(node)
{
return node.getPath('contentId', ';').substring(1).split(';')
},
/**
* @private
* Reload nodes, one by one
* @param {Ext.data.NodeInterface[]} nodes The nodes to be refreshed
* @param {Function} callback At the end
*/
_reloadNodes: function(nodes, callback)
{
if (nodes.length > 0)
{
var me = this,
node = nodes[0];
// We can only load one node at once
Ametys.data.ServerComm.callMethod({
role: this.getInitialConfig('serverRole'),
methodName: 'getNodeInformations',
parameters: this._getNodeInformationsParameters(node),
errorMessage: false, // ignore error
callback: {
handler: function(data) {
// Update node data
let newData = {};
for (var i in data)
{
if (i == "data")
{
for (var j in data[i])
{
newData[j] = data[i][j];
}
}
else
{
newData[i] = data[i];
}
}
newData.leaf = false;
if (newData.id.startsWith('random-id-'))
{
delete newData.id // Changing non-worthy id will lead to an internal modification that would sometimes display the saveBar during a few instants
}
node.set(newData, {commit: true});
me.getStore().load({
node: node,
callback: function() {
me.updateNodeUI(node);
me._reloadNodes(Ext.Array.slice(nodes, 1), callback);
}
});
}
}
});
}
else if (callback)
{
callback();
}
},
/**
* Retrieves from the server information about the content root and set it as root of the tree.
* @param {String} contentId The id of the new content root node
* @param {Object} otherConfig An additional config object to set to the root.
* @param {Function} callback The callback function to call after the root node is set
*/
setContentRootNode: function(contentId, otherConfig, callback)
{
let parameters = this.getContentRootNodeParameters(contentId, otherConfig);
Ametys.data.ServerComm.callMethod({
role: this.getInitialConfig('serverRole'),
methodName: "getRootNodeInformations",
parameters: parameters,
callback: {
handler: this._setContentRootNodeCb,
scope: this,
arguments: {
configToSet: otherConfig || {},
callback: callback
}
},
errorMessage: true,
waitMessage: false
});
},
/**
* @protected
* Works in setContentRootNode server call
* @param {String} contentId The id of the new content root node
* @param {Object} otherConfig An additional config object to set to the root.
*/
getContentRootNodeParameters: function(contentId, otherConfig)
{
return [
contentId,
this.treeId
];
},
/**
* @protected
* Get the parameters to the server call to get node informations
* @param {Model} node The node
* @return {Object[]} The parameters to this._serverRole#getNodeInformations
*/
_getNodeInformationsParameters(node)
{
return [node.get('contentId'), this.getNodePath(node)];
},
/**
* @private
* The callback function of #setContentRootNode
* @param {Object} response The root node information retrieved by the server
* @param {Object} arguments The callback arguments
* @param {Object} arguments.configToSet A additional config object to set to the root
* @param {Function} arguments.callback The callback function to call after the root node is set
*/
_setContentRootNodeCb: function(response, arguments)
{
var newRootCfg = Ext.apply(arguments.configToSet, response);
if (!this.getStore())
{
return;
}
// Rootnode data should be extracted as any other node
var reader = this.getStore().getProxy().getReader();
var model = this.getStore().getModel();
var fieldExtractorInfo = reader.getFieldExtractorInfo(model);
var modelData = reader.extractModelData(newRootCfg, fieldExtractorInfo);
this.setRootNode(modelData);
this.getStore().removedNodes = []; // Bug fix for this that is not reinit correctly when changing root node and leads to bug when rejectingChanges
this._onRootNodeLoad(this.getStore().root);
if (Ext.isFunction(arguments.callback))
{
arguments.callback.call();
}
},
/**
* @protected
* Called when loading the root node
* @param {Ext.data.Model} record The root node
*/
_onRootNodeLoad: function(record)
{
// Nothing
}
});