/*
* Copyright 2013 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 to display a set of attributes.
* Each attribute can be checked or unchecked.
* By default, the tree includes a toolbar which allow to select/deselect every attribute in the tree.
* @private
*/
Ext.define('Ametys.plugins.cms.content.tree.ViewTree', {
extend: 'Ext.tree.Panel',
statics: {
/**
* @private
* @readonly
* @property {RegExp} _duplicateSlashes Utility regexp used to remove duplicate slashes in a path.
*/
_duplicateSlashes: /\/+/g
},
cls: 'view-tree',
rootVisible: false,
animate: true,
minHeight: 200,
maxHeight: 600,
/**
* @property {Object} rootNodeConfig Default configuration for the root node.
* @protected
*/
rootNodeConfig: {
text: 'root',
id: 'root',
expanded: false
},
/**
* @cfg {Boolean} adaptTreePanelSize Adapt the height of the tree between min and max height on node collapse/expand.
*/
adaptTreePanelSize: false,
/**
* @cfg {String} contentId Identifier of a content which content type will be used to the view.
* This could be used as a convenience instead of providing a content type. But either #contentType or #contentId is required.
* If both are provided, only #contentId will be taken into account.
*/
/**
* @property {String} _contentId See #cfg-contentId
* @private
*/
/**
* @cfg {String} contentType Content type to use to generate the view.
* Either #contentType or #contentId is required.
* If both are provided, only #contentId will be taken into account.
*/
/**
* @property {String} _contentType See #cfg-contentType
* @private
*/
/**
* @cfg {Boolean} checkMandatory Indicates if mandatory attribute should be checked and disabled. This only applie to main content (the first level of the tree).
* As attribute of type content are either checked by reference (in which case their attributes are not checkable),
* or checked in mode 'create new' and their mandatory attribute will always by checked and disabled.
*/
/**
* @property {Boolean} _checkMandatory See #cfg-checkMandatory
* @private
*/
/**
* @cfg {String} [viewName="default-edition"] The name of the view to display.
*/
/**
* @property {String} _viewName See #cfg-viewName
* @private
*/
/**
* @cfg {String} [fallbackViewName="main"] The ame of the fallback view to display.
*/
/**
* @property {String} _fallbackViewName See #cfg-fallbackViewName
* @private
*/
/**
* @cfg {String} [viewMode="edition"] The mode of the view. 'edition' or 'view'
*/
/**
* @property {String} _viewMode See #viewMode
* @private
*/
/**
* @property {Boolean} _inErrorState Indicates if the tree has encountered an error, this typically means that the desired view could not be loaded.
* @private
*/
/**
* @property {Ext.LoadMask} _loadMask Mask bound to the tree, displayed when the tree is loading.
* @private
*/
/**
* @property {Boolean} _checkByDefault Indicates if the checkboxes should be checked or not.
* @private
*/
/**
* @private
* @property {Ext.Template} _attributeTooltipTpl The template used for attribute's tooltip
*/
_attributeTooltipTpl : Ext.create('Ext.XTemplate', [
'<div class="ametys-tooltip-tree-attribute">',
'<div class="ametys-tooltip-tree-attribute-text">{description}</div>',
'<tpl if="warns && warns.length">',
'<ul class="warns"><tpl for="warns"><li>{.}</li></tpl></ul>',
'</tpl>',
'<tpl if="infos && infos.length">',
'<ul class="infos"><tpl for="infos"><li>{.}</li></tpl></ul>',
'</tpl>',
'<div class="x-clear">',
'</div>',
'</div>'
]),
constructor: function (config)
{
// Store config
config.store = this._getTreeStoreCfg();
// Docked items
config.dockedItems = this._getDockItemsCfg(config);
this.callParent(arguments);
this.on('viewready', this._onViewReady, this);
this.on('itemmouseenter', this._createQtip, this);
if (this.adaptTreePanelSize)
{
this.on('afteritemcollapse', this._onItemCollapse, this);
this.on('afteritemexpand', this._onItemExpand, this);
}
},
/**
* @private
* Destroy and create the node tooltip when the mouse enters the node
* @param {Ext.view.View} view The tree view
* @param {Ext.data.Model} node The tree node
* @param {HTMLElement} el The node's element
*/
_createQtip: function(view, node, el)
{
Ext.QuickTips.unregister(el);
var tooltipCfg = {
target: el,
id: el.id + '-tooltip',
inribbon: false
};
if (node.get('type') == 'error')
{
var errorMsg = "{{i18n plugin.cms:CONTENT_COPY_VIEW_TREE_NODE_ERRORTYPE_TEXT}}";
errorMsg += node.get('viewName') + " / " + node.get('viewMode') + " / " + node.get('contentType');
tooltipCfg.text = errorMsg;
}
else
{
var infos = [];
var warns = [];
if (node.get('mandatory') && node.get('disabled') && node.get('checked'))
{
infos.push("{{i18n plugin.cms:CONTENT_COPY_VIEW_TREE_NODE_TOOLTIP_DESC_MANDATORY}}");
}
if (node.get('mode') == 'create')
{
infos.push("{{i18n plugin.cms:CONTENT_COPY_VIEW_TREE_NODE_TOOLTIP_DESC_MODE_CREATE}}");
}
else if (node.get('mode') == 'reference')
{
infos.push("{{i18n plugin.cms:CONTENT_COPY_VIEW_TREE_NODE_TOOLTIP_DESC_MODE_REFERENCE}}");
}
if (node.get('type') == 'repeater')
{
warns.push("{{i18n plugin.cms:CONTENT_COPY_VIEW_TREE_NODE_TOOLTIP_DESC_REPEATER}}");
}
var text = this._attributeTooltipTpl.applyTemplate({
description: node.get('description'),
infos: infos,
warns: warns
});
tooltipCfg.title = node.get('label');
tooltipCfg.text= text;
}
Ext.QuickTips.register(tooltipCfg);
},
/**
* This method should be called to initialize the tree.
* Either after its creation, or to change the current configuration.
* The view will be reloaded.
*/
initialize: function(config)
{
this._isLoading = true;
// content identifier or content type is mandatory.
this._contentId = config.contentId || null;
this._contentType = config.contentType || null;
this._viewMode = config.viewMode || 'edition';
this._viewName = config.viewName || 'default-edition';
this._fallbackViewName = config.fallbackViewName || 'main';
this._checkMandatory = config.checkMandatory || false;
this._creationNotAllowed = config.creationNotAllowed || false;
this._checkByDefault = config.checkByDefault != null ? config.checkByDefault : true;
this._inErrorState = true; // wait for first load to remove the error state.
// Load store.
var rootNode = this.getStore().getRoot();
if (rootNode.isLoaded())
{
rootNode.removeAll();
this.setRootNode(this.rootNodeConfig);
}
this.getStore().load({
callback: function() {
this._isLoading = false;
this.unmask();
this.selectAndFocusFirstNode();
},
scope: this
});
},
/**
* Select and focus the first node of the tree if it exists
*/
selectAndFocusFirstNode: function()
{
var childNodes = this.getRootNode().childNodes;
if (childNodes[0])
{
var firstNode = childNodes[0];
this.getSelectionModel().select(firstNode);
this.getView().focusNode(firstNode);
}
},
/**
* Returns true if the tree in in error state
*/
isInErrorState : function ()
{
return this._inErrorState;
},
/**
* Retrieves the data of the tree under the form of an object that contains interesting information for each node.
* It go through every node of the tree and call the Ametys.plugins.cms.content.tree.ViewTree.ViewNodeModel#getValues method on the node.
* See {@link Ametys.plugins.cms.content.tree.ViewTree.ViewNodeModel}
* The returned object respect the structure of the tree but only the checked node are present (non checkable, unchecked or error nodes are filtered out).
* This is a short example of the returned object :
* {
* attributeA: null, // values returned by the getValues method of the attributeA node
* attributeB: { // values returned by the getValues method of the attributeB node
* $mode: 'reference'
* },
* attributeC: { // values returned by the getValues method of the attributeB node, plus the child attribute nodes because attributeC has children.
* $mode: 'create',
* $loaded: true,
* attributeC1: null, // etc
* attributeC2: { ... }
* }
* }
* @return {Object} The described object in the above description or null if the tree is in error state.
*/
getValues: function()
{
if (this._inErrorState)
{
return null;
}
var me = this;
var path, sanitizedPath;
var walker;
// Minimal values
var values = {
'$loaded': true
};
var rootNode = this.getRootNode();
rootNode.cascadeBy(function(child) {
if (rootNode == child)
{
// ignore root node.
return;
}
if (child.get('checked') !== true || child.get('type') == 'error')
{
// non-checked or error node encountered
// stop cascading in that branch.
return false;
}
path = child.getPath('name') || '';
sanitizedPath = path.replace(this.self._duplicateSlashes, '/');
walker = values;
Ext.Array.forEach(sanitizedPath.split('/'), function(attribute, index, array) {
if (attribute != '')
{
// walking through the attribute road.
if (index < array.length - 1)
{
walker = walker[attribute] = walker[attribute] || {};
}
else
{
// end of the attribute road, put valuable info.
walker[attribute] = child.getValues()
}
}
});
});
return values;
},
// --------------- Tree store and its listeners --------------------//
/**
* @protected
* Create the tree store
* @param {Object} config The tree panel configuration
* @return {Ext.data.TreeStore} The created tree store
*/
_getTreeStoreCfg: function(config)
{
return {
model: 'Ametys.plugins.cms.content.tree.ViewTree.ViewNodeModel',
autoLoad: false,
proxy: {
type: 'ametys',
plugin: 'cms',
url: 'view/definition.json',
reader: {
type: 'json',
rootProperty: function(data) {
if (data.elements)
{
return Object.values(data.elements);
}
if (data.type == 'content')
{
return null;
}
return [];
}
}
},
root: this.rootNodeConfig,
listeners: {
beforeload: {fn: this._onBeforeLoad, scope: this},
load: {fn: this._onLoad, scope: this},
nodebeforeappend: {fn: this._onBeforeNodeAppend, scope: this},
nodeappend: {fn: this._onNodeAppend, scope: this}
}
};
},
/**
* @private
* Listen to the *beforeload* of the tree store
* Inject extra parameters to the load request
* See {@link Ext.data.TreeStore#event-beforeload}
*/
_onBeforeLoad: function (store, operation, eOpts)
{
var params = Ext.apply(operation.getParams() || {}, {
viewMode: this._viewMode,
viewName: this._viewName,
fallbackViewName: this._fallbackViewName
});
var node = operation.node;
if (node == null || node.isRoot())
{
if (this._contentId)
{
Ext.apply(params, {
contentId: this._contentId
});
}
else if (this._contentType)
{
Ext.apply(params, {
contentType: this._contentType
});
}
}
else
{
// Loading a node representing an attribute of type content.
Ext.apply(params, {
contentType: node.get('contentType'),
viewName: node.get('viewName') || this._viewName,
fallbackViewName: node.get('fallbackViewName') || this._fallbackViewName
});
}
// Cancel the load request if unable to set mandatory parameters.
if (!params.contentId && !params.contentType)
{
return false;
}
// Set params
operation.setParams(params);
},
/**
* Listener called after the store is loaded.
* Auto expand the root node and check for errors.
* @param {Ext.data.TreeStore} store The tree store
* @param {Ext.data.TreeModel[]} records The loaded records
* @param {Boolean} successful True if the operation was successful.
* @param {Ext.data.Operation} operation The operation that triggered this load.
* @param {Ext.data.NodeInterface} node The node that was loaded.
* @private
*/
_onLoad: function(store, records, successful, operation, node)
{
if (successful && !operation.aborted && node.isRoot())
{
node.expand();
var errorInFirstLevel = false;
Ext.Array.each(records, function(record) {
errorInFirstLevel = record.get('type') == 'error';
return errorInFirstLevel; // stops iteration if false.
});
if (!errorInFirstLevel)
{
this._inErrorState = false;
}
}
},
/**
* @private
* Listen to the beforeappend event of the tree store, to perform additional treatments on the record.
* @param {Ext.data.NodeInterface} node The current node
* @param {Ext.data.NodeInterface} child The child node to be appended
*/
_onBeforeNodeAppend: function(node, child)
{
// CMS-5144 remove root title attribute in copy content mode
if (this._checkMandatory && child.get('contentAncestor') == null && child.get('path') == 'title')
{
// Cancel the append.
return false;
}
},
/**
* @private
* Listen to append event, to perform additional treatments on the record.
* This method mainly calls the internal initialization method of the appended node.
* @param {Ext.data.NodeInterface} node the parent node
* @param {Ext.data.NodeInterface} child the newly appended node
* @param {Number} index the index of the newly appended node
*/
_onNodeAppend: function(node, child, index)
{
if (child.get('type') == 'error')
{
child.onError();
}
else
{
// Search for an ancestor of type 'content'.
// If found, this attribute does not belong to the main content.
var contentAncestor = child.getParent({
role: null,
type: 'content'
});
child.set('contentAncestor', contentAncestor);
// check mandatory is ignored if there is a content ancestor because this will involve a creation of a new content.
// In this case, we want mandatory attribute to always be disabled.
var forceMandatory = (!!contentAncestor || this._checkMandatory);
child.set('forceMandatory', forceMandatory);
child.set('checkByDefault', this._checkByDefault);
if (!this._checkByDefault && this._checkMandatory && child.get('mandatory') && this._parentOrSelfNotAnUnselectedContentOrRepeater(node))
{
// When all nodes are not checked by default and the current node is mandatory, we need to check the parents unless content ref
let cursor = node;
while (cursor != null)
{
cursor.set('checked', true);
cursor = cursor.getParent();
}
}
// do not allow creation
if (this._creationNotAllowed == true && child.get('role') == null && child.get('type') == 'content')
{
child.setCannotCreate();
}
child.initializeCheckStatus();
}
},
/**
* @private
* The node is not a content or a repeater and the same for its parents nodes
*/
_parentOrSelfNotAnUnselectedContentOrRepeater: function(node)
{
let cursor = node;
while (cursor != null)
{
if (cursor.get('type') == 'content' && cursor.get('mode') != 'create'
|| cursor.get('type') == 'repeater')
{
return false;
}
cursor = cursor.getParent();
}
return true;
},
// --------------- Tree panel listeners ----------------------//
/**
* On tree view ready listener
* Intercept and override the onCheckChange method of the view of the tree to be able to get finer control over the check events.
* This allows us to intercept check event before the value of the check box changes. This is used for the disabled node, and the tree states nodes.
* @param {Ametys.plugins.cms.content.tree.ViewTree} tree the current tree.
* @private
*/
_onViewReady: function(tree)
{
// Handling load mask...
if (this._isLoading)
{
tree.mask();
}
// Then, real listener logic.
var view = tree.getView();
// onCheckChange overriding.
// Calls the Ametys.plugins.cms.content.tree.ViewTree.ViewNodeModel#setChecked method and fire the checkchange event.
Ext.override(view, {
onCheckChange: function(event)
{
var node = event.record,
checked = node.get('checked');
if (Ext.isBoolean(checked))
{
checked = !checked;
checked = node.setChecked(checked);
this.fireEvent('checkchange', node, checked);
}
}
});
// onCheckChange interceptor. When this function return false, the onCheckChange method of the view is not called, hence the value of the checkbox does not change.
var onCheckChangeInterceptor = function(event)
{
var node = event.record;
if (node.get('disabled'))
{
let ghostCheck = !node.get('disabledChecked'); // it would be better to check if every child is checked... too complex...
node.set('disabledChecked', ghostCheck);
node.setChecked(ghostCheck);
return false;
}
else if (node.get('checked') == node.getNextCheckedState())
{
node.onInterceptedCheck();
return false;
}
};
Ext.apply(view, {
onCheckChange: Ext.Function.createInterceptor(view.onCheckChange, onCheckChangeInterceptor)
});
},
/**
* @private
* afteritemcollapse listener. Used when #adaptTreePanelSize is true to adapt the size of the tree.
* @param {Ametys.plugins.cms.content.tree.ViewTree.ViewNodeModel} node The collapsed node.
*/
_onItemCollapse: function(node)
{
if (this.adaptTreePanelSize)
{
var tableEl = this.getView().getEl().down('table');
if (tableEl)
{
// Real height should be table height + toolbar height + x-scrollbar height
// But this is hardcoded for the moment.
this.setHeight(tableEl.getHeight() + 60);
}
}
},
/**
* @private
* afteritemexpand listener. Used when #adaptTreePanelSize is true to adapt the size of the tree.
* @param {Ametys.plugins.cms.content.tree.ViewTree.ViewNodeModel} node The expanded node.
*/
_onItemExpand: function(node)
{
if (this.adaptTreePanelSize)
{
var tableEl = this.getView().getEl().down('table');
if (tableEl)
{
// Real height should be table height + toolbar height + x-scrollbar height
// But this is hardcoded for the moment.
this.setHeight(tableEl.getHeight() + 60);
}
}
},
// --------------- Docked items / Toolbar ----------------------//
/**
* @protected
* Get the dock items
* @param {Object} config The initial tree panel configuration
* @return {Object[]} The dock items configuration
*/
_getDockItemsCfg: function (config)
{
var dockedItems = config.dockedItems || [];
var toolbarCfg = this._getToolbarCfg();
if (toolbarCfg)
{
toolbarCfg.dock = toolbarCfg.dock || 'top';
dockedItems.push(toolbarCfg);
}
return dockedItems;
},
/**
* @protected
* Retrieves the toolbar config object.
*/
_getToolbarCfg: function()
{
return {
dock: 'top',
xtype: 'toolbar',
layout: {
type: 'hbox',
align: 'stretch'
},
defaultType: 'button',
items: [/*{
// Summary
// FIXME must be implemented? As a button or as a popup that shows up when the user validate its action.
tooltip: "{{i18n CONTENT_COPY_VIEW_BTN_SUMMARY_TOOLTIP}}",
handler: Ext.bind (this._displaySummary, this),
text: "{{i18n CONTENT_COPY_VIEW_BTN_SUMMARY_TEXT}}"
},*/
{
xtype: 'tbspacer',
flex: 1
},
{
// Select all
tooltip: "{{i18n CONTENT_COPY_VIEW_BTN_SELECT_ALL}}",
handler: Ext.bind (this._selectAll, this),
iconCls: 'ametysicon-check51',
cls: 'a-btn-light'
}, {
// Deselect all
tooltip: "{{i18n CONTENT_COPY_VIEW_BTN_DESELECT_ALL}}",
handler: Ext.bind (this._deselectAll, this),
iconCls: 'ametysicon-blank32',
cls: 'a-btn-light'
}]
};
},
/**
* Function acting as a handler for the 'summary' button.
* Displays some information about the node that are currently checked in the tree.
* @param {Ext.button.Button} button The button handling this function
* @private
*/
_displaySummary: function(button)
{
// FIXME displayed information should be user friendly if this feature is implemented.
names = Ext.Array.map(this.getChecked(), function(node) {
var path = node.getPath('name') || '';
return path.replace(this.self._duplicateSlashes, '/') + ' : ' + node.get('text');
}, this);
Ext.MessageBox.show({
title: "{{i18n CONTENT_COPY_VIEW_BTN_SUMMARY_TEXT}}",
msg: names.join('<br/>'),
buttons: Ext.Msg.OK,
icon: Ext.MessageBox.INFO
});
},
/**
* Function acting as a handler for the 'select all' button.
* Checks all the button in tree (except for the button that are restricted).
* @param {Ext.button.Button} button The button handling this function
* @private
*/
_selectAll: function(button)
{
this.getRootNode().eachChild(function(child) {
child.setChecked(true);
});
},
/**
* Function acting as a handler for the 'deselect all' button.
* Unchecks all the button in tree (except for the button that are restricted).
* @param {Ext.button.Button} button The button handling this function
* @private
*/
_deselectAll: function(button)
{
this.getRootNode().eachChild(function(child) {
child.setChecked(false);
});
}
});