/*
* Copyright 2019 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.
*/
/**
* Provides a Tree for displaying query containers and queries.
* @private
*/
Ext.define('Ametys.plugins.queriesdirectory.tree.QueriesTree', {
extend: 'Ext.tree.Panel',
statics: {
/**
* Function to render query's type in result grid
* @param {Object} value The type value
* @param {Object} metaData A collection of metadata about the current cell
* @param {Ext.data.Model} record The record
* @private
*/
renderType: function(value, metaData, record)
{
try
{
var query = Ametys.plugins.queriesdirectory.model.QueryFactory.create(value, record.getId(), {});
return query.getTypeLabel();
}
catch (e)
{
return value;
}
},
/**
* Function to render query's title in result grid
* @param {Object} title The data title
* @param {Object} metaData A collection of metadata about the current cell
* @param {Ext.data.Model} record The record
* @private
*/
renderTitle: function(title, metaData, record)
{
var queryType = record.get('type'),
isQuery = record.get('isQuery'),
iconCls = '';
if (isQuery)
{
iconCls = 'ametysicon-data110';
try
{
metaData.tdAttr = Ametys.plugins.queriesdirectory.tree.QueriesTree.descriptionTooltip(record);
var query = Ametys.plugins.queriesdirectory.model.QueryFactory.create(queryType, record.getId(), {});
iconCls = query.getTypeIconCls();
}
catch (e)
{
// Error while trying to get icon class for this query
}
return '<span class="a-grid-glyph query-entry ' + iconCls /*+ ' ' + record.get('decorator')*/ + '"></span>' + title;
}
else
{
return title;
}
},
/**
* Function to render query's documentation in result grid
* @param {Object} title The data title
* @param {Object} metaData A collection of metadata about the current cell
* @param {Ext.data.Model} record The record
* @private
*/
renderDocumentation: function(documentation, metaData, record)
{
return '<a href="' + documentation + '" target="_blank">' + documentation + '</a>';
},
/**
* Function to render query's title in result grid
* @param {Object} title The data title
* @param {Object} metaData A collection of metadata about the current cell
* @param {Ext.data.Model} record The record
* @private
*/
renderDescription: function(description, metaData, record)
{
if (record.get('isQuery'))
{
metaData.tdAttr = Ametys.plugins.queriesdirectory.tree.QueriesTree.descriptionTooltip(record);
}
return description;
},
/**
* Function to generate description tooltip
* @param {Ext.data.Model} record The record
* @private
*/
descriptionTooltip: function(record)
{
return 'data-qtip="<strong>' + record.get("title").replaceAll('"', '"') + '</strong>'
+ (record.get("description") && record.get("description") !== '' ? '<br/>' + record.get("description").replaceAll('"', '"') : '')
+ (record.get("documentation") && record.get("documentation") !== '' ? '<br/>' + Ametys.plugins.queriesdirectory.tree.QueriesTree.renderDocumentation(record.get("documentation"), null, record).replaceAll('"', '"') : '')
+ '"';
}
},
/**
* @cfg {Boolean} onlyContainers true to only have the containers
*/
/**
* @cfg {String} profile The profile ('read_access' or 'write_access') filter. By default, if null, the server will treat the request as 'read_access'
*/
/**
* @cfg {String} queryType The type of queries to return. Can be null to not filter by type.
*/
/**
* @private
* @property {Object} _lastSearch The criteria used on the last search
*/
_lastSearch: {},
/**
* @private
* @property {Boolean} _isComputingRootNode To prevent multiple requests to refresh the root node.
*/
_isComputingRootNode: false,
constructor: function(config)
{
config.store = this._createStore(config);
if (config.onlyContainers !== true)
{
config.dockedItems = [this._getToolbarCfg(config)];
config.dockedItems.push(this._getNoResultPanel());
}
this._counter = {};
var plugins = config.plugins;
config.plugins = Ext.isArray(plugins) ? plugins : (plugins == null ? [] : [plugins]);
config.plugins.push({
ptype: 'cellediting',
clicksToEdit: 1,
editAfterSelect: true,
listeners: {
'beforeedit': this._onBeforeEdit,
'edit': this._onEdit,
scope: this
}
});
config.viewConfig = config.viewConfig || {};
Ext.apply(config.viewConfig, {
plugins: {
ptype: 'ametystreeviewdragdrop',
containerScroll: true,
appendOnly: true,
sortOnDrop: true,
expandDelay: 500,
setAmetysDragInfos: Ext.bind(this.getDragInfo, this),
setAmetysDropZoneInfos: Ext.bind(this.getDropInfo, this)
}
});
this.callParent(arguments);
Ametys.message.MessageBus.on(Ametys.message.Message.CREATED, this._onCreatedOrMovedMessage, this);
Ametys.message.MessageBus.on(Ametys.message.Message.MODIFIED, this._onUpdatedMessage, this);
Ametys.message.MessageBus.on(Ametys.message.Message.MOVED, this._onCreatedOrMovedMessage, this);
},
onDestroy: function()
{
Ametys.message.MessageBus.unAll(this);
this.callParent(arguments);
},
/**
* @private
* Creates the queries directory store
* @param {Object} config The configuration
* @param {Boolean} [config.onlyContainers=false] See {@link #cfg-onlyContainers}
* @param {String} [config.profile=null] See {@link #cfg-profile}
* @param {String} [config.queryType=null] See {@link #cfg-queryType}
* @return {Ext.data.Store} The query store
*/
_createStore: function(config)
{
var onlyContainers = config.onlyContainers === true,
profile = config.profile,
queryType = config.queryType;
var store = Ext.create('Ext.data.TreeStore', {
model: 'Ametys.plugins.queriesdirectory.tree.QueriesTree.QueryEntry',
sorters: [{property: 'text', direction:'ASC'}],
proxy: {
type: 'ametys',
plugin: 'queries-directory',
url: 'queries/list.json',
reader: {
type: 'json',
rootProperty: 'queries'
},
extraParams: {
onlyContainers: onlyContainers,
profile: profile,
queryType: queryType
}
},
folderSort: true,
root: {
id: 'root',
name: 'root',
editable: false,
allowDrag: false,
isQuery: false,
expanded: true,
text: "{{i18n PLUGINS_QUERIESDIRECTORY_UITOOL_QUERIES_ROOT_NAME}}",
path: ''
}
});
return store;
},
/**
* @private
* Gets the toolbar configuration
* @param {Object} config The configuration
* @return {Object} The toolbar configuration
*/
_getToolbarCfg: function(config)
{
return {
dock: 'top',
xtype: 'toolbar',
layout: {
type: 'hbox',
align: 'stretch'
},
defaultType: 'button',
items: [{
// Search input
xtype: 'textfield',
cls: 'ametys',
flex: 1,
maxWidth: 300,
itemId: 'search-filter-input',
emptyText: "{{i18n PLUGINS_QUERIESDIRECTORY_TREE_FILTER_EMPTY_TEXT}}",
enableKeyEvents: true,
minLength: 3,
minLengthText: "{{i18n PLUGINS_QUERIESDIRECTORY_TREE_FILTER_MIN_LENGTH_INVALID}}",
msgTarget: 'qtip',
listeners: {change: Ext.Function.createBuffered(this._searchFilter, 500, this)}
}, {
// Clear search filter
tooltip: "{{i18n PLUGINS_QUERIESDIRECTORY_TREE_FILTER_CLEAR}}",
handler: this._clearSearchFilter,
scope: this,
iconCls: 'a-btn-glyph ametysicon-eraser11 size-16',
cls: 'a-btn-light'
}, {
xtype: 'tbspacer',
flex: 0.0001
}, {
// Filter by author
tooltip: '{{i18n PLUGINS_QUERIESDIRECTORY_TREE_FILTER_BY_AUTHOR}}',
itemId: 'search-filter-author',
enableToggle: true,
toggleHandler: this._searchFilter,
scope: this,
iconCls: 'a-btn-glyph ametysicon-body-people size-16',
cls: 'a-btn-light'
}, {
xtype: 'tbseparator'
}, {
// Filter by type
xtype: 'querytypefilter',
itemId: 'search-filter-others',
tree: this
}]
}
},
/**
* @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_QUERIESDIRECTORY_UITOOL_QUERIES_FILTER_NO_MATCH}}" + "{{i18n PLUGINS_QUERIESDIRECTORY_UITOOL_QUERIES_FILTER_NO_MATCH_ACTION}}",
scope: this,
handler: this._clearAllSearchFilters
};
},
/**
* @private
* Show or hide the 'no result' button.
* @param {Boolean} show true to show the button, false to hide it.
*/
_showHideNoResultPanel: function (show)
{
this.down("button[itemId='no-result']").setVisible(show);
},
/**
* @private
* Filters queries
*/
_searchFilter: function ()
{
var field = this.getDockedItems('container[dock="top"]')[0].down('#search-filter-input');
var value = new String(field.getValue()).trim();
var search = this.getDockedItems('container[dock="top"]')[0].down('#search-filter-others').getValues();
search.search = value;
search.author = this.getDockedItems('container[dock="top"]')[0].down('#search-filter-author').pressed;
if (Ext.Object.equals(this._lastSearch, search))
{
// Do nothing
return;
}
this._lastSearch = search;
if (search.search.length > 2
|| search.author
|| search.request
|| search.solr
|| search.script
|| search.formatting)
{
var rootNode = this.getRootNode();
this._getFilteredQueries(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
*/
_getFilteredQueries: function (node, childNodesOnly)
{
Ametys.data.ServerComm.callMethod({
role: "org.ametys.plugins.queriesdirectory.QueryDAO",
methodName: 'filterQueries',
parameters: [node.getId(),
this._lastSearch.search.length > 2 ? this._lastSearch.search : '',
this._lastSearch.author,
this._lastSearch.request,
this._lastSearch.solr,
this._lastSearch.script,
this._lastSearch.formatting ],
errorMessage: "{{i18n PLUGINS_QUERIESDIRECTORY_UITOOL_QUERIES_FILTER_ERROR}}",
cancelCode: 'QueriesTree',
callback: {
handler: this._filterQueriesCb,
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
*/
_filterQueriesCb: function(paths, args)
{
var hasResult = false;
var node = args.node;
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;
this._expandAndFilter (paths, this.getRootNode(), node);
}
if (!hasResult)
{
this._showHideNoResultPanel(true);
}
else
{
this._showHideNoResultPanel(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().addFilter({id: 'filter', filterFn: filterFn});
},
/**
* 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.getId()] = paths.length;
for (var i=0; i < paths.length; i++)
{
var path = paths[i].substr(0, paths[i].lastIndexOf("#"));
this.expandPath(path, 'id', '#', 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.getId()] == 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('id', "#");
for (var i=0; i < paths.length; i++)
{
var path = paths[i] + '#';
if (path.indexOf(currentPath + '#') == 0)
{
return true;
}
}
return false;
},
_clearAllSearchFilters: function()
{
this.getDockedItems('container[dock="top"]')[0].down('#search-filter-author').toggle(false, true);
this.getDockedItems('container[dock="top"]')[0].down('#search-filter-others').untoggleAll(true);
this._clearSearchFilter();
},
/**
* @private
* Clears the filter search
*/
_clearSearchFilter: function()
{
this.getDockedItems('container[dock="top"]')[0].down('#search-filter-input').reset();
this._searchFilter();
},
/**
* Clear all filters
*/
clearFilter: function ()
{
this.getStore().removeFilter('filter');
var selection = this.getSelectionModel().getSelection()[0];
if (selection)
{
this.ensureVisible(selection.getPath('id', "#"), {field: 'id'});
}
},
/**
* @private
* Add filter on empty containers on the store
*/
_addEmptyContainersFilter: function()
{
this.getStore().removeFilter('emptyContainers');
this.getStore().addFilter({
id: 'emptyContainers',
filterFn: function(record) {
var hasDescendant = record.get('hasNoDescendantQuery') === false,
isQuery = record.get('isQuery'),
show = isQuery || hasDescendant;
return show;
}
});
},
/**
* @private
* Listener called before cell editing
* @param {Ext.grid.plugin.CellEditing} editor The cell editor
* @param {Object} context An editing context event with the following properties:
* @param {Ext.grid.Panel} context.grid The owning grid Panel.
* @param {Ext.data.Model} context.record The record being edited.
* @param {String} context.field The name of the field being edited.
* @param {Mixed} context.value The field's current value.
* @param {HTMLElement} context.row The grid row element.
* @param {Ext.grid.column.Column} context.column The {@link Ext.grid.column.Column} being edited.
* @param {Number} context.rowIdx The index of the row being edited.
* @param {Number} context.colIdx The index of the column being edited.
* @param {Boolean} context.cancel Set this to `true` to cancel the edit or return false from your handler.
*/
_onBeforeEdit: function(editor, context)
{
var record = context.record;
return record && !record.isRoot() && !record.get('isQuery') && record.get('canWrite');
},
/**
* @private
* Listener called after cell editing
* @param {Ext.grid.plugin.CellEditing} editor The cell editor
* @param {Object} context An editing context with the following properties:
* @param {Ext.grid.Panel} context.grid The owning grid Panel.
* @param {Ext.data.Model} context.record The record being edited.
* @param {String} context.field The name of the field being edited.
* @param {Mixed} context.value The field's current value.
* @param {HTMLElement} context.row The grid row element.
* @param {Ext.grid.column.Column} context.column The {@link Ext.grid.column.Column} being edited.
* @param {Number} context.rowIdx The index of the row being edited.
* @param {Number} context.colIdx The index of the column being edited.
* @param {Mixed} context.originalValue The original value before being edited.
*/
_onEdit: function(editor, context)
{
var record = context.record,
id = record.getId(),
newName = context.value,
oldName = context.originalValue;
if (newName != oldName)
{
Ametys.plugins.queriesdirectory.QueriesDAO.renameQueryContainer([id, newName], renameCb, { ignoreCallbackOnError: false });
function renameCb(response)
{
if (!response || !response.id)
{
// edit failed
record.beginEdit();
record.set('text', oldName);
record.endEdit();
record.commit();
}
}
}
},
/**
* @private
* This event is thrown by the getDragData to add the 'source' of the drag.
* @param {Object} item The default drag data that will be transmitted. You have to add a 'source' item in it:
* @param {Ametys.relation.RelationPoint} item.source The source (in the relation way) of the drag operation.
*/
getDragInfo: function(item)
{
var targets = item.records
.filter(hasRight)
.map(this.getMessageTargetConfiguration)
.filter(function(cfg) {return cfg != null;});
function hasRight(record)
{
return record.get('canDelete');
}
if (targets.length > 0)
{
item.source = {
relationTypes: [Ametys.relation.Relation.MOVE],
targets: targets
};
}
},
/**
* @private
* This event is thrown before the beforeDrop event and create the target of the drop operation relation.
* @param {Ext.data.Model[]} targetRecords The target records of the drop operation.
* @param {Object} item The default drag data that will be transmitted. You have to add a 'target' item in it:
* @param {Object} item.target The target (in the relation way) of the drop operation. A Ametys.relation.RelationPoint config.
*/
getDropInfo: function(targetRecords, item)
{
var targets = targetRecords
.map(this.getMessageTargetConfiguration)
.filter(function(cfg) {return cfg != null});
if (targets.length > 0)
{
item.target = {
relationTypes: [Ametys.relation.Relation.MOVE],
targets: targets
};
}
},
/**
* @private
* Gets the configuration for creating message target
* @param {Ext.data.Model} record The tree record to convert to its Ametys.message.MessageTarget configuration
* @return {Object} The configuration to create a Ametys.message.MessageTarget. Can be null, if the record is null or not relevant to be a messagetarget.
*/
getMessageTargetConfiguration: function(record)
{
if (record == null)
{
return null;
}
else if (record.get('isQuery'))
{
return {
id: Ametys.message.MessageTarget.QUERY,
parameters: {
ids: [record.getId()]
}
};
}
else
{
return {
id: Ametys.message.MessageTarget.QUERY_CONTAINER,
parameters: {
id: record.getId(),
name: record.get('text'),
canWrite: record.get('canWrite'),
canEdit: record.get('canEdit'),
canRename: record.get('canRename'),
canAssignRights: record.get('canAssignRights'),
fullPath: record.getFullPath()
}
};
}
},
/**
* Listener on create messages
* @param {Ametys.message.Message} message The creation message.
* @private
*/
_onCreatedOrMovedMessage: function(message)
{
var queryTarget = message.getTarget(Ametys.message.MessageTarget.QUERY);
var queryContainerTarget = message.getTarget(Ametys.message.MessageTarget.QUERY_CONTAINER);
if (queryTarget)
{
this._onQueryCreatedOrMoved(queryTarget, Ext.emptyFn);
}
else if (queryContainerTarget)
{
var editingPlugin = this.editingPlugin;
this._onQueryCreatedOrMoved(queryContainerTarget, beginEdit);
function beginEdit(created)
{
// TODO startEdit is deprecated, but I was not able to found the "new" way to do it
editingPlugin.startEdit(created, 0);
}
}
},
/**
* Called when a query or query container is created
* @param {Ametys.message.MessageTarget} target The query message target
* @param {Function} cb The callback
* @private
*/
_onQueryCreatedOrMoved: function(target, cb)
{
var tree = this,
store = tree.getStore(),
selModel = tree.getSelectionModel();
selModel.deselectAll(),
createdId = target.getParameters().id;
Ametys.plugins.queriesdirectory.QueriesDAO.getIdsOfPath([createdId], expandAndSelectUntil);
function expandAndSelectUntil(pathIds)
{
pathIds.splice(0, 0, 'root');
loadNextPathEl([store.getById('root')]);
function loadNextPathEl(amongRecords)
{
if (pathIds.length)
{
var pathId = pathIds.shift();
var childNode = Ext.Array.findBy(amongRecords, function(childRecord) {
return childRecord.getId() == pathId;
});
if (childNode)
{
store.load({
node: childNode,
callback: function(records) {
expand(childNode, records);
}
});
}
}
else
{
var created = store.getById(createdId);
if (created)
{
selModel.select(created);
cb(created);
}
}
}
function expand(loadedNode, records)
{
tree.expandNode(loadedNode, false, function() {
loadNextPathEl(records);
});
}
}
},
/**
* Listener on update messages
* @param {Ametys.message.Message} message The edition message.
* @private
*/
_onUpdatedMessage: function(message)
{
var queryTarget = message.getTarget(Ametys.message.MessageTarget.QUERY),
queryContainerTarget = message.getTarget(Ametys.message.MessageTarget.QUERY_CONTAINER);
if (queryTarget)
{
this.reloadParent(queryTarget, true);
}
else if (queryContainerTarget)
{
var store = this.getStore(),
node = store.getNodeById(queryContainerTarget.getParameters().id);
if (node)
{
var newName = queryContainerTarget.getParameters().name;
node.beginEdit();
node.set('text', newName);
node.endEdit();
node.commit();
store.sort();
}
}
},
/**
* Listener on move messages
* @param {Ametys.message.Message} message The moving message.
* @private
*/
_onMovedMessage: function(message)
{
var queryTarget = message.getTarget(Ametys.message.MessageTarget.QUERY),
queryContainerTarget = message.getTarget(Ametys.message.MessageTarget.QUERY_CONTAINER),
store = this.getStore();
if ((queryTarget || queryContainerTarget) && store)
{
store.sort();
}
},
/**
* Reloads parent node
* @param {Ametys.message.MessageTarget} target The message target
* @param {Boolean} keepSelection true to keep old selection
*/
reloadParent: function(target, keepSelection)
{
var queryId = target.getParameters().id,
store = this.getStore(),
node = store.getNodeById(queryId),
parentId = node && node.get('parentId'),
parentNode = store.getNodeById(parentId);
if (parentNode)
{
var tree = this;
var oldSelIds = tree.getSelection()
.map(function(record) {
return record.getId();
});
store.load({node: parentNode, callback: keepSelection ? keepSelectionFn : Ext.emptyFn});
function keepSelectionFn()
{
var selModel = tree.getSelectionModel();
var oldSel = oldSelIds
.map(function(id) {
return store.getNodeById(id);
})
.filter(function(record) { return record != null });
selModel.deselectAll();
selModel.select(oldSel);
}
}
},
/**
* Refresh the whole tree
* @param {Function} [callback] function to call after refreshing
*/
initRootNodeParameter: function (callback)
{
if (this._isComputingRootNode)
{
return;
}
this._isComputingRootNode = true;
Ametys.data.ServerComm.callMethod({
role: "org.ametys.plugins.queriesdirectory.QueryDAO",
methodName: "getRootProperties",
callback: {
scope: this,
handler: this._getRootNodeCb,
arguments: {
callback: callback
}
},
errorMessage: {
category: this.self.getName(),
msg: "{{i18n DAOS_QUERY_ROOT_ERROR}}"
},
waitMessage: true
});
},
/**
* @private
* Callback function after retrieving root node
* @param {Object} response The server response
* @param {Object} args the callback arguments
*/
_getRootNodeCb: function (response, args)
{
this._isComputingRootNode = false;
this.setRootNode({
path: '',
name: 'root',
canWrite: response.canWrite,
canRename: false,
canAssignRights: response.canAssignRights,
canDelete: false,
expanded: true,
text: "{{i18n PLUGINS_QUERIESDIRECTORY_UITOOL_QUERIES_ROOT_NAME}}"
});
if (Ext.isFunction (args.callback))
{
this.store.getRoot().on('expand', function() { args.callback (response.id); }, this, { single: true });
}
}
});