/*
* Copyright 2021 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 the components of a form.
* @private
*/
Ext.define('Ametys.plugins.forms.tree.FormTree', {
extend: 'Ext.tree.Panel',
/**
* @property {String} _formId The id of the current root of the tree
* @private
*/
_formId : null,
/**
* @cfg {Object[]} activeIndicators The current active indicators
*/
/**
* @property {Object[]} _activeIndicators The active indicators
* @private
*/
_activeIndicators: {},
/**
* @property {Object} _availableFilters The object containing the available filter functions (or filter function factories if they depend on another argument)
* @private
*/
_availableFilters: {
'search': function(searchedValue) {
return function(record){
function isLike(rec) {
var title = Ext.String.deemphasize(rec.get('text')).toLowerCase(),
search = Ext.String.deemphasize(searchedValue).toLowerCase();
var test = new RegExp(".*(" + search + ").*").test(title);
var needExpand = rec.get('type') == 'page' && !rec.isExpanded();
if (needExpand) {
rec.expand();
}
return test || rec.childNodes.filter(isLike).length > 0;
}
var type = record.get('type');
if (type == 'root')
{
// Always show root
return true;
}
else
{
return isLike(record);
}
}
}
},
constructor: function(config)
{
this._indicators = config.indicators || [];
this._activeIndicators = this._getDefaultActiveIndicators();
config.store = this._createStore(config);
//header of the tree
config.dockedItems = [this._getToolbarCfg()];
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
}
});
this.callParent(arguments);
var me = this;
var view = this.view.lockedView || this.view;
var tpl = new Ext.XTemplate(view.cellTpl.html.replace('</div></td>',
'<tpl for="values.column.getItemId().startsWith(\'tree\') ? this.getActiveIndicators() : []">' // startsWith(\'tree\') to apply to the main column only
+ '<tpl if="this.matchIndicator(values, parent.record)">'
+ '{% out.push(this.applyIndicator(values, parent.record)) %}'
+ '</tpl>'
+ '</tpl>'
+ '</div></td>'),
{
priority: view.cellTpl.priority,
getActiveIndicators: function() {
// We could directly return me activeIndicators; but the order would change following the order of clicking on the checkbox
return me._indicators.map(function(i) { return i.id;})
.filter(function(i) { return me._activeIndicators[i]});
},
matchIndicator: function(id, record) {
return Ext.Array.findBy(me._indicators, function(v) { return v.id == id;}).matchFn.call(me, record);
},
applyIndicator: function(id, record) {
return Ext.Array.findBy(me._indicators, function(v) { return v.id == id;}).applyFn.call(me, record);
}
});
if (this.view.lockedView)
{
this.view.lockedView.cellTpl = tpl;
}
else
{
this.view.cellTpl = tpl;
}
this.on('afterrender', this._afterRender, this);
Ametys.message.MessageBus.on(Ametys.message.Message.CREATED, this._onMessageCreated, this);
Ametys.message.MessageBus.on(Ametys.message.Message.MODIFIED, this._onUpdatedMessage, this);
},
/**
* After render tree, check or uncheck indicators
* @private
*/
_afterRender: function()
{
var me = this;
var indicatorMenu = this.down('toolbar > button[itemId="indicators-menu"]');
if (indicatorMenu)
{
indicatorMenu.getMenu().items.each(function(item) {
item.setChecked(me._activeIndicators[item.name], true);
});
}
},
applyState: function (state)
{
//this.callParent(arguments) do not call the parent because the columns size do not be recorded in the state
this._activeIndicators = {}
Object.assign(this._activeIndicators, this._getDefaultActiveIndicators())
Object.assign(this._activeIndicators, state.activeIndicators)
},
getState: function ()
{
var state = {}; //this.callParent(arguments) do not call the parent because the columns size do not be recorded in the state
// save indicators
state.activeIndicators = this._activeIndicators
return state;
},
/**
* Get default active indicators
* @private
*/
_getDefaultActiveIndicators: function()
{
var defaultIndicators = {};
for (var i in this._indicators)
{
defaultIndicators[this._indicators[i].id] = this._indicators[i].defaultValue || false;
}
return defaultIndicators;
},
onDestroy: function()
{
Ametys.message.MessageBus.unAll(this);
this.callParent(arguments);
},
/**
* Creates the form store
* @param {Object} config The configuration
* @param {String} [config.profile=null] See {@link #cfg-profile}
* @return {Ext.data.Store} The form store
* @private
*/
_createStore: function(config)
{
var store = Ext.create('Ext.data.TreeStore', {
model: 'Ametys.plugins.forms.tree.FormsTree.FormEntry',
asynchronousLoad: false,
proxy: {
type: 'ametys',
plugin: 'forms',
url: 'forms/structure.json',
reader: {
type: 'json',
rootProperty: 'pages'
}
},
root: {
id: 'root',
isForm: true,
text: "{{i18n PLUGINS_FORMS_UITOOL_FORMS_ROOT_NAME}}"
}
});
return store;
},
/**
* Init the root node parameters
* @param {Function} callback the callback function after getting form properties
* @param {String} formId the form id
*/
initRootNodeParameter: function (callback, formId)
{
this._formId = formId;
Ametys.data.ServerComm.callMethod({
role: "org.ametys.plugins.forms.dao.FormDAO",
methodName: "getFormProperties",
parameters: [formId, false, false],
callback: {
scope: this,
handler: this._getRootNodeCb,
arguments: {
callback: callback
}
},
errorMessage: {
category: this.self.getName(),
msg: "{{i18n DAOS_FORM_ROOT_ERROR}}"
},
waitMessage: {target: this}
});
},
/**
* Gets the toolbar configuration
* @return {Object} The toolbar configuration
* @private
*/
_getToolbarCfg: function()
{
var toolbarCfg = {
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_FORMS_TREE_FILTER}}",
minLength: 3,
minLengthText: "{{i18n PLUGINS_FORMS_TREE_FILTER_INVALID}}",
msgTarget: 'qtip',
listeners: {change: Ext.Function.createBuffered(this._searchFilter, 500, this)},
style: {
marginRight: '0px'
}
},
{
// Clear filter
tooltip: "{{i18n PLUGINS_FORMS_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_FORMS_TREE_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_FORMS_TREE_REFRESH_NODE}}",
handler: Ext.bind (this._refreshCurrentNode, this, [], false),
iconCls: 'a-btn-glyph ametysicon-arrow123 size-16',
cls: 'a-btn-light'
}
]
}
var menuItems = [];
var me = this;
Ext.Array.forEach(this._indicators, function(indicator) {
menuItems.push({
xtype: 'menucheckitem',
iconCls: indicator.iconGlyph,
text: indicator.label,
tooltip: indicator.description,
name: indicator.id,
itemId: indicator.id,
value: indicator.defaultValue,
checkHandler: me._selectIndicator,
scope: me
})
});
toolbarCfg.items.push({
tooltip: "{{i18n PLUGINS_FORMS_TREE_INDICATORS_TOOTIP}}",
iconCls: 'a-btn-glyph ametysicon-puzzle33 size-16',
cls: 'a-btn-light',
itemId: 'indicators-menu',
menu: {
xtype: 'menu',
items: menuItems
}
});
return toolbarCfg;
},
/**
* Listener when an indicator is checked/unchecked
* @param {Ext.menu.CheckItem} item the item
* @param {Boolean} checked the checked status
* @private
*/
_selectIndicator: function(item, checked)
{
var oldValue = this._activeIndicators[item.name];
var hasChanges = oldValue !== checked;
this._activeIndicators[item.name] = checked;
if (hasChanges)
{
this.saveState();
this.view.refresh();
}
},
/**
* Callback function after retrieving root node
* @param {Object} response The server response
* @param {Object} args the callback arguments
* @private
*/
_getRootNodeCb: function (response, args)
{
this.setRootNode({
expanded: true,
type: 'root',
text: response.title,
id: response.id,
isConfigured: response.isConfigured
});
if (Ext.isFunction (args.callback))
{
this.store.getRoot().on('expand', function() { args.callback (response.id); }, this, { single: true });
}
},
/**
* Listener before edit
* @param {Ext.grid.plugin.CellEditing} editor the cell editor
* @param {Object} context The editing context
* @private
*/
_onBeforeEdit: function(editor, context)
{
var record = context.record;
return record && !record.isRoot() && record.get('canWrite');
},
/**
* Listener called after cell editing
* @param {Ext.grid.plugin.CellEditing} editor The cell editor
* @param {Object} context The editing context
* @private
*/
_onEdit: function(editor, context)
{
var record = context.record,
id = record.getId(),
newName = context.value,
oldName = context.originalValue;
if (newName != oldName)
{
if(record.getData().type == "page")
{
Ametys.plugins.forms.dao.FormPageDAO.renamePage([id, newName], renameCb, { ignoreCallbackOnError: false })
}
if(record.getData().type == "question")
{
Ametys.plugins.forms.dao.FormQuestionDAO.renameQuestion([id, newName], renameCb, { ignoreCallbackOnError: false })
}
function renameCb(response)
{
if (!response || !response.id)
{
// edit failed
record.beginEdit();
record.set('text', oldName);
record.endEdit();
record.commit();
}
}
}
},
/**
* Listener on creation message.
* @param {Ametys.message.Message} message The creation message.
* @private
*/
_onMessageCreated: function(message)
{
var target = message.getTarget(Ametys.message.MessageTarget.FORM_PAGE),
questionTarget = message.getTarget(Ametys.message.MessageTarget.FORM_QUESTION);
var editingPlugin = this.editingPlugin;
if (target)
{
this.onFormComponentCreated(target.getParameters().id, beginEdit);
}
if ( questionTarget)
{
this.onFormComponentCreated(questionTarget.getParameters().id, beginEdit);
}
function beginEdit(created)
{
// FIXME startEdit is deprecated, but I was not able to found the "new" way to do it
editingPlugin.startEdit(created, 0);
}
},
/**
* Listener on form component created
* @param {Ametys.message.MessageTarget} target The message target
* @param {Function} cb The callback
* @private
*/
onFormComponentCreated: function(createdId, cb)
{
var tree = this,
store = tree.getStore(),
selModel = tree.getSelectionModel(),
record = store.getById(createdId);
selModel.deselectAll();
if (record)
{
selModel.select(record);
}
else
{
Ametys.plugins.forms.dao.FormPageDAO.getIdsOfPath([createdId], Ext.bind(expandAndSelectUntil, this));
}
function expandAndSelectUntil(pathIds)
{
pathIds.splice(0, 0, this._formId);
loadNextPathEl([store.getById(this._formId)]);
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);
created.expand();
cb(created);
}
}
}
function expand(loadedNode, records)
{
tree.expandNode(loadedNode, false, function() {
loadNextPathEl(records);
});
}
}
},
/**
* Refresh the page node
* @param {String} pageId The page id.
* @private
*/
refreshPageNode: function(pageId)
{
var tree = this,
store = tree.getStore(),
record = store.getById(pageId);
if (record)
{
this._refreshNode(record);
}
},
/**
* Listener on update messages
* @param {Ametys.message.Message} message The edition message.
* @private
*/
_onUpdatedMessage: function(message)
{
var questionTarget = message.getTarget(Ametys.message.MessageTarget.FORM_QUESTION),
pageTarget = message.getTarget(Ametys.message.MessageTarget.FORM_PAGE),
formTarget = message.getTarget(Ametys.message.MessageTarget.FORM_TARGET);
if (questionTarget)
{
this.reloadParent(questionTarget, true, Ext.bind(this._refreshIsConfiguedAttribute, this));
}
else if (pageTarget || formTarget)
{
this._setLocalName(pageTarget || formTarget);
}
},
/**
* Set the isConfigured attribute to the page
* @param {String} pageId the page id
* @private
*/
_refreshIsConfiguedAttribute: function(pageId)
{
var parentPageNode = this.getStore().getNodeById(pageId);
var isConfigured = this._isConfiguredPage(parentPageNode);
parentPageNode.set("isConfigured", isConfigured);
this._refreshNode(parentPageNode)
},
/**
* Return true if the page node is well configured
* @param {Object} pageNode the page node
* @private
*/
_isConfiguredPage(pageNode)
{
var isConfigured = true;
for(var i in pageNode.childNodes)
{
var child = pageNode.childNodes[i];
if (!child.get("isConfigured"))
{
isConfigured = false;
}
}
return isConfigured;
},
/**
* Set node local name
* @param {Ametys.message.MessageTarget} target The message target
* @private
*/
_setLocalName: function(target)
{
var store = this.getStore(),
node = store.getNodeById(target.getParameters().id);
if (node)
{
var newName = target.getParameters().title;
node.beginEdit();
node.set('text', newName);
node.endEdit();
node.commit();
}
},
/**
* Reloads parent node
* @param {Ametys.message.MessageTarget} target The message target
* @param {Boolean} keepSelection true to keep old selection
* @public
*/
reloadParent: function(target, keepSelection, cb)
{
var id = target.getParameters().id,
store = this.getStore(),
node = store.getNodeById(id),
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.bind(cb, this, [parentId])});
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);
cb(parentId);
}
}
},
/**
* 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)
{
this._addSearchFilter (value);
}
else
{
this._clearFilter();
}
},
/**
* Add filter on title on the store
* @private
*/
_addSearchFilter: function(value)
{
var id = 'search',
filterFactory = this._availableFilters[id],
filterFn = filterFactory(value),
filterCfg = {
id: id,
filterFn: filterFn
};
this.getStore().removeFilter(id);
this.getStore().addFilter(filterCfg);
},
/**
* Clear the filter search if exists
* @private
*/
_clearSearchFilter: function()
{
this._clearFilter();
if (this._filterField)
{
this._filterField.reset();
}
this._filterValue = null;
var selection = this.getSelectionModel().getSelection()[0];
if (selection)
{
this.ensureVisible(selection.getPath('name'), {field: 'name'});
}
},
/**
* Clear all filters
* @private
*/
_clearFilter: function ()
{
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.
* @private
*/
_collapseNode: function (node)
{
node = node || this.getRootNode();
node.collapseChildren(true);
this.getSelectionModel().select(node);
},
/**
* This function reload the current node
* @private
*/
_refreshCurrentNode: 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)
{
this._refreshNode(node);
}
},
/**
* This function reload the given node
* @private
*/
_refreshNode: function(node)
{
// Set leaf to false, to allow children to be added during the load. Leaf will be set to true again if needed after the load.
node.set('leaf', false);
var me = this;
this.getStore().load({
node: node,
callback: function () {
me._updateNodeUI(node);
if (me._filterValue && me._filterValue.length > 2)
{
me._addSearchFilter (this._filterValue);
}
else
{
Ext.defer(this.expandNode, 200, this, [node]);
}
},
scope: this
});
},
/**
* Updates the node UI (icon, text, ...)
* @param {Ext.data.NodeInterface} node the node
* @private
*/
_updateNodeUI: function(node)
{
this.view.refreshNode(node);
}
});