mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-08-26 04:26:01 +02:00
This edge case can occur when a large tree is cached in HTML already, without any nodes expanded via ajax. If a new node is added with a parent that's not existant, it was simply placed on the root node. This is a display bug, a full CMS refresh fixes it. Fixes a related bug where tree causes (view) duplicates, where the same node is rendered twice. This was due to the whole subtree being refreshed (including the new node) through jstree's built-in "open"/"select" events, while at the same time creating a new node through updateNodesFromServer() callbacks. Also added a global tree loading indication to make it clear that the tree is still processing. See https://github.com/silverstripe/silverstripe-cms/issues/872
501 lines
16 KiB
JavaScript
501 lines
16 KiB
JavaScript
/**
|
|
* File: LeftAndMain.Tree.js
|
|
*/
|
|
|
|
(function($) {
|
|
|
|
$.entwine('ss.tree', function($){
|
|
|
|
$('.cms-tree').entwine({
|
|
|
|
Hints: null,
|
|
|
|
IsUpdatingTree: false,
|
|
|
|
IsLoaded: false,
|
|
|
|
onadd: function(){
|
|
this._super();
|
|
|
|
// Don't reapply (expensive) tree behaviour if already present
|
|
if($.isNumeric(this.data('jstree_instance_id'))) return;
|
|
|
|
var hints = this.attr('data-hints');
|
|
if(hints) this.setHints($.parseJSON(hints));
|
|
|
|
/**
|
|
* @todo Icon and page type hover support
|
|
* @todo Sorting of sub nodes (originally placed in context menu)
|
|
* @todo Automatic load of full subtree via ajax on node checkbox selection (minNodeCount = 0)
|
|
* to avoid doing partial selection with "hidden nodes" (unloaded markup)
|
|
* @todo Disallow drag'n'drop when node has "noChildren" set (see siteTreeHints)
|
|
* @todo Disallow moving of pages marked as deleted
|
|
* most likely by server response codes rather than clientside
|
|
* @todo "defaultChild" when creating a page (sitetreeHints)
|
|
* @todo Duplicate page (originally located in context menu)
|
|
* @todo Update tree node title information and modified state after reordering (response is a JSON array)
|
|
*
|
|
* Tasks most likely not required after moving to a standalone tree:
|
|
*
|
|
* @todo Context menu - to be replaced by a bezel UI
|
|
* @todo Refresh form for selected tree node if affected by reordering (new parent relationship)
|
|
* @todo Cancel current form load via ajax when new load is requested (synchronous loading)
|
|
*/
|
|
var self = this;
|
|
this
|
|
.jstree(this.getTreeConfig())
|
|
.bind('loaded.jstree', function(e, data) {
|
|
self.setIsLoaded(true);
|
|
|
|
// Add ajax settings after init period to avoid unnecessary initial ajax load
|
|
// of existing tree in DOM - see load_node_html()
|
|
data.inst._set_settings({'html_data': {'ajax': {
|
|
'url': self.data('urlTree'),
|
|
'data': function(node) {
|
|
var params = self.data('searchparams') || [];
|
|
// Avoid duplication of parameters
|
|
params = $.grep(params, function(n, i) {return (n.name != 'ID' && n.name != 'value');});
|
|
params.push({name: 'ID', value: $(node).data("id") ? $(node).data("id") : 0});
|
|
params.push({name: 'ajax', value: 1});
|
|
return params;
|
|
}
|
|
}}});
|
|
|
|
self.updateFromEditForm();
|
|
self.css('visibility', 'visible');
|
|
|
|
// Only show checkboxes with .multiple class
|
|
data.inst.hide_checkboxes();
|
|
})
|
|
.bind('before.jstree', function(e, data) {
|
|
if(data.func == 'start_drag') {
|
|
// Don't allow drag'n'drop if multi-select is enabled'
|
|
if(!self.hasClass('draggable') || self.hasClass('multiselect')) {
|
|
e.stopImmediatePropagation();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if($.inArray(data.func, ['check_node', 'uncheck_node'])) {
|
|
// don't allow check and uncheck if parent is disabled
|
|
var node = $(data.args[0]).parents('li:first');
|
|
var allowedChildren = node.find('li:not(.disabled)');
|
|
|
|
// if there are child nodes that aren't disabled, allow expanding the tree
|
|
if(node.hasClass('disabled') && allowedChildren == 0) {
|
|
e.stopImmediatePropagation();
|
|
return false;
|
|
}
|
|
}
|
|
})
|
|
.bind('move_node.jstree', function(e, data) {
|
|
if(self.getIsUpdatingTree()) return;
|
|
|
|
var movedNode = data.rslt.o, newParentNode = data.rslt.np, oldParentNode = data.inst._get_parent(movedNode), newParentID = $(newParentNode).data('id') || 0, nodeID = $(movedNode).data('id');
|
|
var siblingIDs = $.map($(movedNode).siblings().andSelf(), function(el) {
|
|
return $(el).data('id');
|
|
});
|
|
|
|
$.ajax({
|
|
'url': self.data('urlSavetreenode'),
|
|
'type': 'POST',
|
|
'data': {
|
|
ID: nodeID,
|
|
ParentID: newParentID,
|
|
SiblingIDs: siblingIDs
|
|
},
|
|
success: function() {
|
|
$('.cms-edit-form :input[name=ParentID]').val(newParentID);
|
|
self.updateNodesFromServer([nodeID]);
|
|
},
|
|
statusCode: {
|
|
403: function() {
|
|
$.jstree.rollback(data.rlbk);
|
|
}
|
|
}
|
|
});
|
|
})
|
|
// Make some jstree events delegatable
|
|
.bind('select_node.jstree check_node.jstree uncheck_node.jstree', function(e, data) {
|
|
$(document).triggerHandler(e, data);
|
|
});
|
|
},
|
|
onremove: function(){
|
|
this.jstree('destroy');
|
|
this._super();
|
|
},
|
|
|
|
'from .cms-container': {
|
|
onafterstatechange: function(e){
|
|
this.updateFromEditForm();
|
|
// No need to refresh tree nodes, we assume only form submits cause state changes
|
|
}
|
|
},
|
|
|
|
'from .cms-container form': {
|
|
onaftersubmitform: function(e){
|
|
var id = $('.cms-edit-form :input[name=ID]').val();
|
|
// TODO Trigger by implementing and inspecting "changed records" metadata
|
|
// sent by form submission response (as HTTP response headers)
|
|
this.updateNodesFromServer([id]);
|
|
}
|
|
},
|
|
|
|
getTreeConfig: function() {
|
|
var self = this;
|
|
return {
|
|
'core': {
|
|
'initially_open': ['record-0'],
|
|
'animation': 0,
|
|
'html_titles': true
|
|
},
|
|
'html_data': {
|
|
// 'ajax' will be set on 'loaded.jstree' event
|
|
},
|
|
'ui': {
|
|
"select_limit" : 1,
|
|
'initially_select': [this.find('.current').attr('id')]
|
|
},
|
|
"crrm": {
|
|
'move': {
|
|
// Check if a node is allowed to be moved.
|
|
// Caution: Runs on every drag over a new node
|
|
'check_move': function(data) {
|
|
var movedNode = $(data.o), newParent = $(data.np),
|
|
isMovedOntoContainer = data.ot.get_container()[0] == data.np[0],
|
|
movedNodeClass = movedNode.getClassname(),
|
|
newParentClass = newParent.getClassname(),
|
|
// Check allowedChildren of newParent or against root node rules
|
|
hints = self.getHints(),
|
|
disallowedChildren = [],
|
|
hintKey = newParentClass ? newParentClass : 'Root',
|
|
hint = (hints && typeof hints[hintKey] != 'undefined') ? hints[hintKey] : null;
|
|
|
|
// Special case for VirtualPage: Check that original page type is an allowed child
|
|
if(hint && movedNode.attr('class').match(/VirtualPage-([^\s]*)/)) movedNodeClass = RegExp.$1;
|
|
|
|
if(hint) disallowedChildren = (typeof hint.disallowedChildren != 'undefined') ? hint.disallowedChildren : [];
|
|
var isAllowed = (
|
|
// Don't allow moving the root node
|
|
movedNode.data('id') !== 0
|
|
// Only allow moving node inside the root container, not before/after it
|
|
&& (!isMovedOntoContainer || data.p == 'inside')
|
|
// Children are generally allowed on parent
|
|
&& !newParent.hasClass('nochildren')
|
|
// movedNode is allowed as a child
|
|
&& (!disallowedChildren.length || $.inArray(movedNodeClass, disallowedChildren) == -1)
|
|
);
|
|
|
|
return isAllowed;
|
|
}
|
|
}
|
|
},
|
|
'dnd': {
|
|
"drop_target" : false,
|
|
"drag_target" : false
|
|
},
|
|
'checkbox': {
|
|
'two_state': true
|
|
},
|
|
'themes': {
|
|
'theme': 'apple',
|
|
'url': $('body').data('frameworkpath') + '/thirdparty/jstree/themes/apple/style.css'
|
|
},
|
|
// Caution: SilverStripe has disabled $.vakata.css.add_sheet() for performance reasons,
|
|
// which means you need to add any CSS manually to framework/admin/scss/_tree.css
|
|
'plugins': [
|
|
'html_data', 'ui', 'dnd', 'crrm', 'themes',
|
|
'checkbox' // checkboxes are hidden unless .multiple is set
|
|
]
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Function:
|
|
* search
|
|
*
|
|
* Parameters:
|
|
* (Object) data Pass empty data to cancel search
|
|
* (Function) callback Success callback
|
|
*/
|
|
search: function(params, callback) {
|
|
if(params) this.data('searchparams', params);
|
|
else this.removeData('searchparams');
|
|
this.jstree('refresh', -1, callback);
|
|
},
|
|
|
|
/**
|
|
* Function: getNodeByID
|
|
*
|
|
* Parameters:
|
|
* (Int) id
|
|
*
|
|
* Returns
|
|
* DOMElement
|
|
*/
|
|
getNodeByID: function(id) {
|
|
return this.find('*[data-id='+id+']');
|
|
},
|
|
|
|
/**
|
|
* Creates a new node from the given HTML.
|
|
* Wrapping around jstree API because we want the flexibility to define
|
|
* the node's <li> ourselves. Places the node in the tree
|
|
* according to data.ParentID.
|
|
*
|
|
* Parameters:
|
|
* (String) HTML New node content (<li>)
|
|
* (Object) Map of additional data, e.g. ParentID
|
|
* (Function) Success callback
|
|
*/
|
|
createNode: function(html, data, callback) {
|
|
var self = this,
|
|
parentNode = data.ParentID ? self.getNodeByID(data.ParentID) : false,
|
|
newNode = $(html);
|
|
|
|
this.jstree(
|
|
'create_node',
|
|
parentNode.length ? parentNode : -1,
|
|
'last',
|
|
'',
|
|
function(node) {
|
|
var origClasses = node.attr('class');
|
|
// Copy attributes
|
|
for(var i=0; i<newNode[0].attributes.length; i++){
|
|
var attr = newNode[0].attributes[i];
|
|
node.attr(attr.name, attr.value);
|
|
}
|
|
node.addClass(origClasses).html(newNode.html());
|
|
callback(node);
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Updates a node's state in the tree,
|
|
* including all of its HTML, as well as its position.
|
|
*
|
|
* Parameters:
|
|
* (DOMElement) Existing node
|
|
* (String) HTML New node content (<li>)
|
|
* (Object) Map of additional data, e.g. ParentID
|
|
*/
|
|
updateNode: function(node, html, data) {
|
|
var self = this, newNode = $(html), origClasses = node.attr('class');
|
|
|
|
var nextNode = data.NextID ? this.getNodeByID(data.NextID) : false;
|
|
var prevNode = data.PrevID ? this.getNodeByID(data.PrevID) : false;
|
|
var parentNode = data.ParentID ? this.getNodeByID(data.ParentID) : false;
|
|
|
|
// Copy attributes. We can't replace the node completely
|
|
// without removing or detaching its children nodes.
|
|
$.each(['id', 'style', 'class', 'data-pagetype'], function(i, attrName) {
|
|
node.attr(attrName, newNode.attr(attrName));
|
|
});
|
|
|
|
// To avoid conflicting classes when the node gets its content replaced (see below)
|
|
// Filter out all previous status flags if they are not in the class property of the new node
|
|
origClasses = origClasses.replace(/status-[^\s]*/, '');
|
|
|
|
// Replace inner content
|
|
var origChildren = node.children('ul').detach();
|
|
node.addClass(origClasses).html(newNode.html()).append(origChildren);
|
|
|
|
if (nextNode && nextNode.length) {
|
|
this.jstree('move_node', node, nextNode, 'before');
|
|
}
|
|
else if (prevNode && prevNode.length) {
|
|
this.jstree('move_node', node, prevNode, 'after');
|
|
}
|
|
else {
|
|
this.jstree('move_node', node, parentNode.length ? parentNode : -1);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Sets the current state based on the form the tree is managing.
|
|
*/
|
|
updateFromEditForm: function() {
|
|
var node, id = $('.cms-edit-form :input[name=ID]').val();
|
|
if(id) {
|
|
node = this.getNodeByID(id);
|
|
if(node.length) {
|
|
this.jstree('deselect_all');
|
|
this.jstree('select_node', node);
|
|
} else {
|
|
// If form is showing an ID that doesn't exist in the tree,
|
|
// get it from the server
|
|
this.updateNodesFromServer([id]);
|
|
}
|
|
} else {
|
|
// If no ID exists in a form view, we're displaying the tree on its own,
|
|
// hence to page should show as active
|
|
this.jstree('deselect_all');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Reloads the view of one or more tree nodes
|
|
* from the server, ensuring that their state is up to date
|
|
* (icon, title, hierarchy, badges, etc).
|
|
* This is easier, more consistent and more extensible
|
|
* than trying to correct all aspects via DOM modifications,
|
|
* based on the sparse data available in the current edit form.
|
|
*
|
|
* Parameters:
|
|
* (Array) List of IDs to retrieve
|
|
*/
|
|
updateNodesFromServer: function(ids) {
|
|
if(this.getIsUpdatingTree() || !this.getIsLoaded()) return;
|
|
|
|
var self = this, i, includesNewNode = false;
|
|
this.setIsUpdatingTree(true);
|
|
self.jstree('save_selected');
|
|
|
|
var correctStateFn = function(node) {
|
|
// Duplicates can be caused by the subtree reloading through
|
|
// a tree "open"/"select" event, while at the same time creating a new node
|
|
self.getNodeByID(node.data('id')).not(node).remove();
|
|
|
|
self.jstree('deselect_all');
|
|
self.jstree('select_node', node);
|
|
// Similar to jstree's correct_state, but doesn't remove children
|
|
var hasChildren = (node.children('ul').length > 0);
|
|
node.toggleClass('jstree-leaf', !hasChildren);
|
|
if(!hasChildren) node.removeClass('jstree-closed jstree-open');
|
|
};
|
|
|
|
// TODO 'initially_opened' config doesn't apply here
|
|
self.jstree('open_node', this.getNodeByID(0));
|
|
self.jstree('save_opened');
|
|
self.jstree('save_selected');
|
|
|
|
$.ajax({
|
|
url: $.path.addSearchParams(this.data('urlUpdatetreenodes'), 'ids=' + ids.join(',')),
|
|
dataType: 'json',
|
|
success: function(data, xhr) {
|
|
$.each(data, function(nodeId, nodeData) {
|
|
var node = self.getNodeByID(nodeId);
|
|
|
|
// If no node data is given, assume the node has been removed
|
|
if(!nodeData) {
|
|
self.jstree('delete_node', node);
|
|
return;
|
|
}
|
|
|
|
// Check if node exists, create if necessary
|
|
if(node.length) {
|
|
self.updateNode(node, nodeData.html, nodeData);
|
|
setTimeout(function() {
|
|
correctStateFn(node) ;
|
|
}, 500);
|
|
} else {
|
|
includesNewNode = true;
|
|
|
|
// If the parent node can't be found, it might have not been loaded yet.
|
|
// This can happen for deep trees which require ajax loading.
|
|
// Assumes that the new node has been submitted to the server already.
|
|
if(nodeData.ParentID && !self.find('li[data-id='+nodeData.ParentID+']').length) {
|
|
self.jstree('load_node', -1, function() {
|
|
newNode = self.find('li[data-id='+nodeId+']');
|
|
correctStateFn(newNode);
|
|
});
|
|
} else {
|
|
self.createNode(nodeData.html, nodeData, function(newNode) {
|
|
correctStateFn(newNode);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
if(!includesNewNode) {
|
|
self.jstree('deselect_all');
|
|
self.jstree('reselect');
|
|
self.jstree('reopen');
|
|
}
|
|
},
|
|
complete: function() {
|
|
self.setIsUpdatingTree(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
});
|
|
|
|
$('.cms-tree.multiple').entwine({
|
|
onmatch: function() {
|
|
this._super();
|
|
this.jstree('show_checkboxes');
|
|
},
|
|
onunmatch: function() {
|
|
this._super();
|
|
this.jstree('uncheck_all');
|
|
this.jstree('hide_checkboxes');
|
|
},
|
|
/**
|
|
* Function: getSelectedIDs
|
|
*
|
|
* Returns:
|
|
* (Array)
|
|
*/
|
|
getSelectedIDs: function() {
|
|
return $.map($(this).jstree('get_checked'), function(el, i) {return $(el).data('id');});
|
|
}
|
|
});
|
|
|
|
$('.cms-tree li').entwine({
|
|
|
|
/**
|
|
* Function: setEnabled
|
|
*
|
|
* Parameters:
|
|
* (bool)
|
|
*/
|
|
setEnabled: function(bool) {
|
|
this.toggleClass('disabled', !(bool));
|
|
},
|
|
|
|
/**
|
|
* Function: getClassname
|
|
*
|
|
* Returns PHP class for this element. Useful to check business rules like valid drag'n'drop targets.
|
|
*/
|
|
getClassname: function() {
|
|
var matches = this.attr('class').match(/class-([^\s]*)/i);
|
|
return matches ? matches[1] : '';
|
|
},
|
|
|
|
/**
|
|
* Function: getID
|
|
*
|
|
* Returns:
|
|
* (Number)
|
|
*/
|
|
getID: function() {
|
|
return this.data('id');
|
|
}
|
|
});
|
|
|
|
$('.cms-content-batchactions input[name=view-mode-batchactions]').entwine({
|
|
onmatch: function() {
|
|
// set active by default
|
|
this.redraw();
|
|
this._super();
|
|
},
|
|
onunmatch: function() {
|
|
this._super();
|
|
},
|
|
onclick: function(e) {
|
|
this.redraw();
|
|
},
|
|
redraw: function(type) {
|
|
if(window.debug) console.log('redraw', this.attr('class'), this.get(0));
|
|
|
|
$('.cms-tree')
|
|
.toggleClass('draggable', !this.is(':checked'))
|
|
.toggleClass('multiple', this.is(':checked'));
|
|
}
|
|
});
|
|
});
|
|
}(jQuery));
|