/*
* 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 is the model for nodes of a view tree. See {@link Ametys.plugins.cms.content.tree.ViewTree}
* These node contains an precise internal logic that is strongly tied to the tree.
* @private
*/
Ext.define('Ametys.plugins.cms.content.tree.ViewTree.ViewNodeModel', {
extend: 'Ext.data.Model',
fields: [
// Common properties
{name: 'text', mapping: 'label', type: 'string'},
{name: 'expanded', type: 'boolean', defaultValue: false},
// Fields for attribute //
{name: 'type', defaultValue: null},
{name: 'name', defaultValue: null},
{name: 'label', defaultValue: null},
{name: 'description', defaultValue: null},
{name: 'path', defaultValue: null},
{name: 'mandatory', mapping: 'validation.mandatory', type: 'boolean', defaultValue: false},
{name: 'contentAncestor', type: 'auto', defaultValue: null} /* A reference to the nearest ancestor which attribute type is content (if any) */,
// end attribute //
// Fields for CONTENT attribute
{name: 'contentType', defaultValue: null},
{name: 'mode', defaultValue: null} /* reference or create */,
{name: 'canCreate', type: 'boolean', defaultValue: true},
{name: 'viewName', defaultValue: null},
{name: 'fallbackViewName', defaultValue: null},
{name: 'viewMode', defaultValue: null},
// end CONTENT attribute
// Field for fieldset
{name: 'role', defaultValue: null},
// end fieldset
{name: 'disabled', type: 'boolean', defaultValue: false},
{name: 'forceMandatory', type: 'boolean', defaultValue: false} /* true if this attribute have to be always checked */,
// Node interface properties with custom logic.
{
name: 'checked',
type: 'auto',
defaultValue: null,
depends: ['disabled'],
convert: function(value, node)
{
if (!node)
{
return value;
}
// changing checked state is not allowed if the node is disabled.
return node.get('disabled') ? node.get('checked') : value;
}
}, {
name: 'iconCls',
depends: ['type', 'role'],
convert: function (value, node)
{
if (!node)
{
return value;
}
if (node.get('role') != null)
{
return 'tree-icon-fieldset';
}
else
{
return 'tree-icon-' + node.get('type');
}
}
}, {
name: 'cls',
depends: ['type', 'role'],
// If value is passed (and not falsy), value for 'cls' will be set to value. Else, it will be set to the defaut value for this node type.
convert: function (value, node)
{
if (!node || value)
{
return value;
}
else
{
var cls = [];
if (node.get('role') != null)
{
cls.push('tree-cell-fieldset');
}
else if (node.get('type') != 'error')
{
cls.push('tree-cell-attribute');
cls.push('tree-cell-' + node.get('type'));
}
return cls.join(' ');
}
}
}
],
/**
* Return an object holding the interesting parameters for this node.
* @return {Object} A value object for this node.
*/
getValues: function()
{
var values = null;
var isContentMeta = this._isContentAttribute();
if (isContentMeta)
{
values = {};
if (isContentMeta)
{
values = { '$mode': this.get('mode') };
}
var viewName = this.get('viewName');
if (viewName)
{
values['$viewName'] = viewName;
}
var fallbackViewName = this.get('fallbackViewName');
if (fallbackViewName)
{
values['$fallbackViewName'] = fallbackViewName;
}
if (this.isLoaded())
{
values['$loaded'] = true;
}
}
return values;
},
/**
* Retrieves the nearest parent that match with the config object passed as argument.
* @param {Object} config To be eligible, a parent should have its properties match each properties of this object.
* @return {Ametys.plugins.cms.content.tree.ViewTree.ViewNodeModel} The matching parent or null.
*/
getParent: function(config)
{
var parent = this.parentNode;
if (!config || Ext.Object.getSize(config) === 0)
{
return parent;
}
var match = false;
while (!match && parent)
{
Ext.Object.each(config, function(key, value) {
match = parent.get(key) === value;
return match; // if false, iteration is stopped.
});
if (!match)
{
parent = parent.parentNode;
}
}
return parent;
},
/**
* Function to call when a node enter in error state.
*/
onError: function()
{
if (this.parentNode.get('type') == 'content')
{
this.parentNode.setCannotCreate();
}
this._updateNodeInfos();
},
/**
* Prevent the 'create' mode for the node
* @private
*/
setCannotCreate: function()
{
// only applicable to node representing a content attribute.
this.set('canCreate', false);
this.initializeCheckStatus();
},
/**
* Initialize (or re-initialize) a node and the status of its check box.
*/
initializeCheckStatus: function()
{
if (this.get('checked') != null)
{
return;
}
var checked = this.getInitialCheckValue();
if (checked != null)
{
this.set('checked', checked);
if (this._isContentAttribute())
{
if (checked == true)
{
// Set 'reference' mode for attribute of type content
this.setMode('reference');
}
else
{
this.setMode('');
}
}
if (this._isDisabled())
{
this.setDisabled();
}
if (this.parentNode)
{
this.parentNode.onChildInitialized();
}
}
if (this._isMandatory())
{
this.setMandatoryCls();
}
this._updateNodeInfos();
// mark as not dirty anymore. This is to avoid a undesired visual feedback when the text property is modified.
this.commit();
},
/**
* Determines if the node should be marked as mandatory
* @return true if the node should be marked as mandatory
* @private
*/
_isMandatory: function ()
{
return (this.get('checked') !== false && this.get('mandatory')) || (this.get('checked') === true && this.get('disabled'))
},
/**
* Determines if it is an attribute of type "content"
* @return true if the type is "content"
* @private
*/
_isContentAttribute: function ()
{
return this.get('type') == 'content';
},
/**
* Determines if the content node is in create mode
* @return true if the content node is in create mode
*/
_isCreateMode: function ()
{
return this.get('mode') == 'create';
},
/**
* Indicates if the node should be disabled or node, given its properties.
* @return {Boolean} true if the node must be disabled.
* @private
*/
_isDisabled: function()
{
if (this._isContentAttribute() && this.get('canCreate'))
{
// Attribute node of type 'content' is never disabled except if create mode is not allowed
return false;
}
if (this.get('path') == 'title' && this.parentNode == this.get('contentAncestor')) // (this.parentNode.isRoot() && forceMandatory) || this.parentNode == this.get('contentAncestor'))
{
// Title attribute must be always disabled when a content creation is involved.
return true;
}
return this.get('forceMandatory') && this.get('mandatory');
},
/**
* Retrieves the initial value for the check box of this node.
* @return {Boolean} true/false correspond to check/unchecked value for the box. If null, the box does not have a checkbox in its initial state.
* @private
*/
getInitialCheckValue: function()
{
var checked = this.get('checked');
if (this.get('canCreate') == false && checked != null)
{
// do not change
return checked;
}
var contentAncestor = this.get('contentAncestor');
if (contentAncestor && contentAncestor.get('mode') != 'create')
{
// Remove checkbox
return null;
}
if (this.get('contentAncestor') == null && this.get('path') == 'title')
{
return false;
}
if (this.get('forceMandatory') && this.get('mandatory'))
{
return true;
}
return this.get('checkByDefault');
},
/**
* Setter for the disabled attribute.
* The set('disabled, true/false) method should never be used on a node. This method must be used.
* @param {Boolean} value the disable value, true to disable, false to enable.
*/
setDisabled: function(value)
{
var oldDisabled = this.get('disabled');
var disable = value !== false;
this.set('disabled', disable);
if (oldDisabled != this.get('disabled'))
{
// Update css classes
var disabledCls = 'tree-cell-disabled';
if (disable === true)
{
var cls = this.get('cls');
cls += (cls ? ' ' : '') + disabledCls;
this.set('cls', cls);
}
else
{
clsArray = this.get('cls').split(/\s+/);
Ext.Array.remove(clsArray, disabledCls);
this.set('cls', clsArray.join(' '));
}
}
},
/**
* Setter for the CSS class that denote a mandatory node.
* @param {Boolean} value true if mandatory, false to remove this class.
*/
setMandatoryCls: function(value)
{
var set = value !== false;
// Update css classes
var mandatoryCls = 'tree-cell-mandatory';
var cls = '';
if (set === true)
{
cls = this.get('cls');
cls += (cls ? ' ' : '') + mandatoryCls;
}
else
{
clsArray = this.get('cls').split(/\s+/);
Ext.Array.remove(clsArray, mandatoryCls);
cls = clsArray.join(' ');
}
this.beginEdit();
this.set('cls', cls);
this.endEdit();
// mark as not dirty anymore. This is to avoid a undesired visual feedback when the text property is modified.
this.commit();
},
/**
* Update the node informations (text and tooltip)
* @private
*/
_updateNodeInfos: function()
{
this._updateNodeText();
this.commit();
},
/**
* Update the node text
* @private
*/
_updateNodeText: function ()
{
if (this.get('type') == 'error')
{
this.set('text', "{{i18n plugin.cms:CONTENT_COPY_VIEW_TREE_NODE_ERRORTYPE}}");
}
else
{
var text = this.get('label');
if (this._isMandatory())
{
text += ' <span class="mandatory-highlight">*</span>';
}
// details depending on the mode
if (this.get('mode') == 'create')
{
text += ' <span class="title-mode">' + "{{i18n plugin.cms:CONTENT_COPY_VIEW_TREE_NODE_TITLE_MODE_CREATE}}" + '</span>';
}
else if (this.get('mode') == 'reference')
{
text += ' <span class="title-mode">' + "{{i18n plugin.cms:CONTENT_COPY_VIEW_TREE_NODE_TITLE_MODE_REFERENCE}}" + '</span>';
}
this.set('text', text);
}
},
/**
* Function called on the parent when one of its child is initialized.
* Ensure the consistency of the disabled attribute of the parent.
* The parent will bubble the method call to its own parent if its status has changed
* @private
*/
onChildInitialized: function()
{
if (this.get('checked') && (this.get('type') == 'composite' || this.get('role') != null))
{
if (this.hasChildNodes())
{
var oldDisabled = this.get('disabled');
var hasChildLocked = false;
this.eachChild(function(child) {
if (!child._checkedStateCanChange())
{
hasChildLocked = true;
return false; // stop iteration
}
});
this.setDisabled(hasChildLocked);
if (oldDisabled != this.get('disabled') && this.parentNode)
{
this.parentNode.onChildInitialized();
}
}
}
},
/**
* Retrieves the next checked value for the current node.
* For a lot of node, the cycle is true -> false -> true -> false etc..
* But the content attribute can have a tree state cycle of the type true -> true -> false etc... (see 'mode' attribute)
* @return {Boolean} its next checked value.
*/
getNextCheckedState: function()
{
var checked = this.get('checked');
// Attribute node of type content have tree states:
// false / true by reference / true create new content.
if (checked
&& this.get('type') == 'content'
&& this.get('canCreate')
&& (this.get('mode') == 'reference' || (this.get('mode') == 'create' && this.get('forceMandatory') && this.get('mandatory'))))
{
// When the node is in mode true by reference, its next checked is
// still true (and its mode will be set to 'create')
// When the node is in mode 'create', its next checked state is false
// except if the node marked as mandatory, in which case it should
// switch to the reference mode
return checked;
}
return !checked;
},
/**
* Called when a check as been intercepted. This is used to update the internal attributes of the node with changing its state value.
*/
onInterceptedCheck: function()
{
this.goToNextState();
},
/**
* Update the mode of the node.
* Only applicable to content attribute.
* Standard cycle is : 'reference' -> 'create' -> ''. Where empty mode correspond to the unchecked check box.
* But there are exception when a node cannotCreate ('create' mode is not allowed)
* or when a mode is mandatory and mandatory are restricted ('' mode is not allowed).
* @private
*/
goToNextState: function()
{
// Currently only attribute node of type content manage an internal state.
if (this.get('type') == 'content')
{
var oldMode = this.get('mode');
switch(oldMode)
{
case 'reference':
if (this.get('canCreate'))
{
this.setMode('create');
}
else
{
this.setMode('');
}
break;
case 'create':
if (this.get('forceMandatory') && this.get('mandatory'))
{
this.setMode('reference');
}
else
{
this.setMode('');
}
break;
case '':
this.setMode('reference');
break;
}
this.onModeChanged(oldMode, this.get('mode'));
}
},
/**
* Setter for the mode attribute. Only this method should be used.
* Also update the CSS classes of the node.
* @param {String} value the mode to set.
*/
setMode: function(value)
{
var oldMode = this.get('mode');
this.set('mode', value);
if (oldMode != this.get('mode'))
{
// Update css classes
var clsPrefix = 'tree-cell-mode-';
clsArray = this.get('cls').split(/\s+/);
clsToRemove = Ext.Array.filter(clsArray, function(cls, index) {
return Ext.String.startsWith(cls, clsPrefix);
});
Ext.Array.forEach(clsToRemove, function(cls) {
Ext.Array.remove(clsArray, cls);
});
if (value)
{
clsArray.push(clsPrefix + value);
}
this.set('cls', clsArray.join(' '));
}
this._updateNodeInfos();
// mark as not dirty anymore. This is to avoid a undesired visual feedback when the text property is modified.
this.commit();
},
/**
* Called when the mode of a node change.
* Cascade into the children of the node to update their current state (add/remove checkbox, initialization etc...)
* @param {String} oldMode the previous mode of the node
* @param {String} newMode the new mode of the node.
* @private
*/
onModeChanged: function(oldMode, newMode)
{
// Currently only attribute node of type content manage an internal state.
if (this.get('type') == 'content')
{
var me = this;
if (oldMode == 'create')
{
this.cascadeBy(function(child) {
if (child == me) return; // ignore current node
if (child.get('checked') != null)
{
child.setDisabled(false);
child.set('checked', null);
// mode management for attribute of type content.
if (this._isContentAttribute())
{
child.setMode('');
}
}
else
{
return false; // stop cascading in that branch.
}
});
}
if (newMode == 'create')
{
this.cascadeBy(function(child) {
if (child == me) return; // ignore current node
if (child.get('checked') == null)
{
child.getOwnerTree()._onNodeAppend(child.getParent(), child);
if (this._isContentAttribute())
{
return false; // stop cascading in that branch.
}
}
else
{
return false; // stop cascading in that branch.
}
});
}
}
},
/**
* Setter for the 'checked' attribute of the node. Only this method should be used.
* Update the current value of the node, propage the changes in its childre and bubble the change in the parents.
* @param {Boolean} checked The checked value of the checkbox. null to not display a checkbox.
*/
setChecked: function(checked)
{
var oldChecked = this.get('checked');
var newChecked;
if (oldChecked == null)
{
return oldChecked;
}
// propagate in children
if (checked == false && this._isContentAttribute() && this._isCreateMode())
{
// nothing to propagate, as child nodes won't be checkable anymore.
this._updateCheckedState(checked);
}
else
{
this.cascadeBy(function(child) {
newChecked = child._updateCheckedState(checked);
if (newChecked == null)
{
// stop cascading in that branch.
return false;
}
/*
Since CMS-10958, this part is commented because it prevents to uncheck a parent node having a checked and disabled child (like mandatory attribute).
Then all children keep their checked state.
It seems to work correctly for now.
// If checked state didn't change for this child, parents checked state must be restored.
// So, bubble in parent.
if (child.parentNode.lastChild === child)
{
child._bubbleCheck();
}
*/
});
}
newChecked = this.get('checked');
if (newChecked != oldChecked)
{
this._bubbleCheck();
}
return this.get('checked');
},
/**
* Internal method used to bubble check change in parent nodes.
* @private
*/
_bubbleCheck: function()
{
// ParentNode must be checkable and its checked state must not be locked.
var parentNode = this.parentNode;
if (parentNode && parentNode.get('checked') != null && parentNode._checkedStateCanChange())
{
var oldChecked = parentNode.get('checked');
var hasChildChecked = false;
var oneCheckedChildCanChange = false;
parentNode.eachChild(function(child) {
if (child.get('checked'))
{
hasChildChecked = true;
if (child._checkedStateCanChange())
{
oneCheckedChildCanChange = true;
return false; // stop iteration
}
}
});
var parentType = parentNode.get('type');
// Also do not bubble an uncheck if non mandatory repeater or attribute of type content
// because the user might want to select those without any child (ie. empty repeater or empty content).
if (oneCheckedChildCanChange || (parentType != 'repeater' && parentType != 'content'))
{
var newChecked = parentNode._updateCheckedState(hasChildChecked);
if (oldChecked != newChecked)
{
parentNode._bubbleCheck();
}
}
}
},
/**
* Internal helper method indicated if a node can change its checked value.
* Disabled and (sometimes) mandatory cannot change there checked value.
* @return {Boolean} true if the checked attribute of the node can change.
* @private
*/
_checkedStateCanChange: function()
{
return !this.get('disabled') && !(this.get('forceMandatory') && this.get('mandatory'));
},
/**
* Internal method to update the current value of the checked attribute of a node.
* Ensure the integrity of the 'checked' and 'mode' attributes of this node.
* @param {Boolean} checked the desired checked value for the node.
* @return {Boolean} The new checked value of the node.
* @private
*/
_updateCheckedState: function(checked)
{
var oldChecked = this.get('checked');
if (oldChecked == null)
{
return oldChecked;
}
if (this._checkedStateCanChange())
{
this.set('checked', checked);
}
var newChecked = this.get('checked');
this._checkNodeStateIntegrity(checked, newChecked);
return newChecked;
},
/**
* Ensure the integrity of the 'mode' attribute of this node when its 'checked' attribute has recenlty changed.
* @param {Boolean} desiredCheck the desired checked value for the node during its last update.
* @param {Boolean} checked The checked value that has been set to the node. Can differs from the desiredCheck under some circumstances (disabled node, tree states etc..).
* @private
*/
_checkNodeStateIntegrity: function(desiredCheck, checked)
{
if (this.get('type') == 'content')
{
var oldMode = this.get('mode');
var newMode = null;
if (checked && oldMode == '')
{
newMode = 'reference';
}
else if (!checked && oldMode != '')
{
newMode = '';
}
// If uncheck was desired but the node cannot changed its state, the node mode is always reset to 'reference' (instead of '').
else if (!this._checkedStateCanChange() && oldMode == 'create' && desiredCheck == false)
{
// reset to reference mode
newMode = 'reference';
}
if (newMode != null && oldMode != newMode)
{
this.setMode(newMode);
this.onModeChanged(oldMode, newMode);
}
}
}
});