/** * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; under version 2 * of the License (non-upgradable). * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * Copyright (c) 2014-2017 Open Assessment Technologies SA; */ /** * Tree provider : jstree * * @author Bertrand Chevrier */ define([ 'jquery', 'lodash', 'i18n', 'context', 'core/store', 'core/promise', 'util/url', 'layout/generisRouter', 'layout/actions', 'layout/section', 'layout/permissions', 'ui/feedback', 'uri', 'jquery.tree' ], function($, _, __, context, store, Promise, urlUtil, generisRouter, actionManager, sectionManager, permissionsManager, feedback, uri){ 'use strict'; var pageRange = 30; return { /** * Tree provider name */ name : 'jstree', /** * The tree factory helps you to instantiate a new tree from the TAO ontology * @exports layout/tree/provider/jstree * * @param {jQueryElement} $container - that will contain the tree * @param {Object} [options] - additional configuration options * @param {String} [options.url] - the endpoint to load data * @param {String} [options.rootClassUri] - the URI of the root class * @param {Object} [options.serverParameters] - add parameters to send to the endpoint (defaults are hideInstance, filter, offset and limit) * @param {Object} [options.actions] - which actions to perform from the tree * @param {String} [options.actions.moveInstance] - the id of the action bound (using actionManager.register) on move * @param {String} [options.actions.selectInstance] - the id of the action bound (using actionManager.register) on item selection * @param {String} [options.actions.selectClass] - the id of the action bound (using actionManager.register) on class selection * @param {String} [options.actions.deleteInstance] - the id of the action bound (using actionManager.register) on delete * @param {String} [options.selectNode] - the URI of the node to be selected by default, the node must be loaded. * @param {String} [options.loadNode] - the URI of a node to be loaded from the server side and selected. * @returns {Promise} resolves when the tree is ready */ init : function init($container, options){ var lastOpened; var lastSelected; var moreNode = { data : __('More'), type : 'more', attributes : { class : 'more' } }; //these are the parameters added to the server call to load data var serverParams = _.defaults(options.serverParameters || {}, { extension : context.shownExtension, perspective : context.shownStructure, section : context.section, // eslint-disable-next-line no-undefined classUri : options.rootClassUri ? options.rootClassUri : undefined, hideInstances : options.hideInstances || 0, filter : '*', offset : 0, limit : pageRange }); //list of events callbacks to be bound to the tree var events = { /** * Refresh the tree * * @event layout/tree#refresh.taotree * @param {Object} [data] - some data to bind to the tree * @param {String} [data.filter] - reload the tree in filtering mode * @param {String} [data.selectNode] - reload the tree and select the given node (by URI) if it is already loaded. * @param {String} [data.loadNode] - the URI of a node to display in filtering mode (it will load only this node) */ refresh : function refresh(data){ var treeState, node; var tree = $.tree.reference($container); if(tree){ // try to select the node within the current loaded tree if (data && data.loadNode) { node = $container.find('[data-uri="' + data.loadNode + '"]'); if (node.length) { tree.select_branch(node); return; } } //update the state with data to be used later (ie. filter value, etc.) treeState = _.merge($container.data('tree-state') || {}, data); treeState = _.omit(treeState, 'selectNode'); if (data && data.loadNode) { tree.deselect_branch(tree.selected); tree.settings.selected = false; treeState.selectNode = data.loadNode; } else if (data && data.selectNode) { //node will be selected in `onload` function tree.deselect_branch(tree.selected); tree.settings.selected = false; } setTreeState(treeState); tree.refresh(); } }, /** * Rollback the tree. * The rollback state must have been set in the state previously, otherwise runs a refresh. * * @event layout/tree#rollback.taotree */ rollback : function rollback(){ var treeState; var tree = $.tree.reference($container); if(tree){ treeState = $container.data('tree-state'); if(treeState.rollback){ $.tree.rollback(treeState.rollback); //remove the rollback infos. setTreeState(_.omit(treeState, 'rollback')); } else { //trigger a full refresh $container.trigger('refresh.taotree'); } } }, /** * Add a node to the tree. * * @event layout/tree#addnode.taotree * @param {Object} data - the data about the node to add * @param {String} data.parent - the id/uri of the node that will contain the new node * @param {String} data.id - the id of the new node * @param {String} data.cssClass - the css class for the new node (node-instance or node-class at least). */ addnode : function addnode(data) { var tree = $.tree.reference($container); var parentNode = tree.get_node($('#' + uri.encode(data.parent), $container).get(0)); var params = _.clone(serverParams); params.classUri = data.parent; if (data.cssClass === 'node-class') { params.hideInstances = 1; //load only class nodes } else { params.loadNode = data.uri; //load particular instance } //load tree branch with new node to get new node permissions $.ajax(tree.settings.data.opts.url, { type : tree.settings.data.opts.method, dataType : tree.settings.data.type, async : tree.settings.data.async, data : params, success : function (response) { var treeData = getTreeData(response); var items = treeData.children || treeData; var node = _.filter(items, function (child) { return child.attributes && child.attributes['data-uri'] === data.uri; }); if (node.length) { tree.select_branch( tree.create(node[0], parentNode) ); } } }); }, /** * Remove a node from the tree. * * @event layout/tree#removenode.taotree * @param {Object} data - the data about the node to remove * @param {String} data.id - the id of the node to remove */ removenode : function removenode(data){ var tree = $.tree.reference($container); var node = tree.get_node($('#' + data.id, $container).get(0)); tree.remove(node); }, /** * Select a node * * @event layout/tree#selectnode.taotree * @param {Object} data - the data about the node to select * @param {String} data.id - the id of the node to select */ selectnode : function selectnode(data){ var tree = $.tree.reference($container); var node = tree.get_node($('#' + data.id, $container).get(0)); $('li a', $container).removeClass('clicked'); tree.select_branch(node); }, /** * Opens a tree branch * * @event layout/tree#openbranch.taotree * @param {Object} data - the data about the node to remove * @param {String} data.id - the id of the node to remove */ openbranch : function openbranch(data){ var tree = $.tree.reference($container); var node = tree.get_node($('#' + data.id, $container).get(0)); $('li a', $container).removeClass('clicked'); tree.open_branch(node); } }; /** * Options given to the jsTree plugin */ var treeOptions = { //data call data: { type: "json", async : true, opts: { method : "GET", url: options.url } }, //theme ui: { "theme_name" : "css", "theme_path" : context.taobase_www + 'js/lib/jsTree/themes/css/style.css' }, //nodes types types: { "default" : { renameable : false, deletable : true, creatable : true, draggable : function($node) { return $node.hasClass('node-instance') && !$node.hasClass('node-undraggable') && options.actions && options.actions.moveInstance; } } }, //lifecycle callbacks callback: { /** * Delete node callback. * @fires layout/tree#delete.taotree * @returns {undefined} */ ondelete: function ondelete() { $container.trigger('delete.taotree', Array.prototype.slice.call(arguments)); }, /** * Additional parameters to send to the server to retrieve data. * It uses the serverParams object previously defined * @param {jQueryElement} [$node] - the node that represents a class. Used to add the classUri to the call * @returns {Object} params */ beforedata: function beforedata($node) { var treeData = $container.data('tree-state'); var params = _.clone(serverParams); if($node && $node.length){ params.classUri = $node.data('uri'); } if(lastSelected){ params.selected = lastSelected; } //check for additionnal parameters in tree state if(treeData){ //the tree has been loaded/refreshed with the filtering if(_.isString(treeData.filter) && treeData.filter.length){ params.filter = treeData.filter; treeData = _.omit(treeData, 'filter'); } //the tree has been loaded/refreshed with the loadNode parameter, so it has to be selected if(_.isString(treeData.loadNode) && treeData.loadNode.length){ params.selected = treeData.loadNode; treeData.selectNode = uri.encode(treeData.loadNode); treeData = _.omit(treeData, 'loadNode'); } setTreeState(treeData); } return params; }, /** * Called back once the data are received. * Used to modify them before building the tree. * * @param {Object} data - the received data * @returns {Object} data the modified data */ ondata: function ondata(data) { var treeData; if(data.error){ feedback().error(data.error); return []; } treeData = getTreeData(data); //automatically open the children of the received node if (treeData.children) { treeData.state = 'open'; } computeSelectionAccess(treeData); needMore(treeData); addTitle(treeData); return treeData; }, /** * Once the data are loaded and the tree is ready * Used to modify them before building the tree. * * @param {Object} tree - the tree instance * * @fires layout/tree#ready.taotree */ onload: function onload(tree){ var $firstClass = $(".node-class:not(.private):first", $container); var $firstInstance = $(".node-instance:not(.private):first", $container); var treeState = $container.data('tree-state') || {}; var selectNode = treeState.selectNode || options.selectNode; var nodeSelection = function nodeSelection() { //the node to select is given if (selectNodeById(selectNode, tree)) { return; } //after refreshing tree previously node will be already selected. if (tree.selected) { return; } //if selectNode was not given and there is no selected node on the tree then try to find node to select: //try to select the last one if (selectNodeById(lastSelected, tree)) { return; } //or the 1st instance if ($firstInstance.length) { return tree.select_branch($firstInstance); } //or something tree.select_branch($('.node-class,.node-instance', $container).get(0)); }; if($firstClass.hasClass('leaf')){ tree.select_branch($firstClass); } else { //open the first class tree.open_branch($firstClass, false, function(){ _.delay(nodeSelection, 10); //delay needed as jstree seems to doesn't know the callbacks right now..., }); } /** * The tree is now ready * @event layout/tree#ready.taotree * @param {Object} [context] - the tree context (uri, classUri) */ $container.trigger('ready.taotree'); }, /** * After a branch is initialized */ oninit : function oninit() { //execute initTree action if (options.actions && options.actions.init) { actionManager.exec(options.actions.init, { uri: $container.data('rootnode') }); } }, /** * Before a branch is opened * @param {HTMLElement} node - the opened node */ beforeopen: function beforeopen(node) { lastOpened = $(node); }, /** * A node is selected. * * @param {HTMLElement} node - the opened node * @param {Object} tree - the tree instance * * @fires layout/tree#change.taotree * @fires layout/tree#select.taotree */ onselect: function onselect(node, tree) { var $node = $(node); var classActions = []; var nodeId = $node.attr('id'); var nodeUri = $node.data('uri'); var $parentNode = tree.parent($node); var nodeContext = { rootClassUri: options.rootClassUri, signature: $node.data('signature') }; lastSelected = nodeId; //mark all unselected $('a.clicked', $container) .parent('li') .not('[id="' + nodeId + '"]') .removeClass('clicked'); //the more node makes you load more resources if($node.hasClass('more')){ loadMore($node, $parentNode, tree); return false; } //exec the selectClass action if ($node.hasClass('node-class')) { if ($node.hasClass('closed')) { tree.open_branch($node); } nodeContext.classUri = nodeId; nodeContext.classSignature = $node.data('signature'); nodeContext.id = nodeUri; nodeContext.context = ['class', 'resource']; //Check if any class-level action is defined in the structures.xml file classActions = _.intersection(_.pluck(options.actions, 'context'), ['class', 'resource', '*']); if (classActions.length > 0) { generisRouter.pushNodeState(location.href, uri.decode(nodeContext.classUri)); executePossibleAction(options.actions, nodeContext, ['delete']); } } //exec the selectInstance action if ($node.hasClass('node-instance')){ nodeContext.uri = nodeId; nodeContext.classUri = $parentNode.attr('id'); nodeContext.classSignature = $parentNode.data('signature'); nodeContext.id = nodeUri; nodeContext.context = ['instance', 'resource']; //the last selected node is stored store('taotree').then(function(treeStore){ treeStore.setItem(context.section, nodeId).then(function(){ generisRouter.pushNodeState(location.href, uri.decode(nodeContext.uri)); executePossibleAction(options.actions, nodeContext, ['moveInstance', 'delete']); }); }); } /** * A node has been selected * @event layout/tree#select.taotree * @param {Object} [context] - the tree context (uri, classUri) */ $container .trigger('select.taotree', [nodeContext]) .trigger('change.taotree', [nodeContext]); return false; }, //when a node is move by drag n'drop onmove: function onmove(node, refNode, type, tree, rollback) { if (!options.actions.moveInstance) { return false; } //do not move an instance into an instance... if ($(refNode).hasClass('node-instance') && type === 'inside') { $.tree.rollback(rollback); return false; } if (type === 'after' || type === 'before') { refNode = tree.parent(refNode); } if (!(refNode instanceof $) && !(refNode instanceof window.HTMLElement)) { $.tree.rollback(rollback); return false; } //set the rollback data setTreeState(_.merge($container.data('tree-state'), {rollback : rollback})); //execute the selectInstance action actionManager.exec(options.actions.moveInstance, { uri: $(node).data('uri'), destinationClassUri: $(refNode).data('uri'), signature: $(node).data('signature'), tree: node }); $container.trigger('change.taotree'); } } }; /** * Set up the tree using the defined options * @private */ var setUpTree = function setUpTree(){ return new Promise( function (resolve) { //bind events from the definition below _.forEach(events, function(callback, name){ $container .off(name + '.taotree') .on(name + '.taotree', function(){ callback.apply(this, Array.prototype.slice.call(arguments, 1)); }); }); //forward some events actionManager.on('refresh', function(node){ var params = node; if(node && node.uri){ params = { loadNode : uri.encode(params.uri) }; } if($container.is(':visible')){ $container.trigger('refresh.taotree', [params]); } }); // workaround to fix dublicate tree bindings on multiple page loads if (!$container.hasClass('tree')) { store('taotree').then(function(treeStore){ treeStore.getItem(context.section).then(function(node){ //create the tree setTreeState({ loadNode: options.loadNode }); $container.tree(treeOptions); sectionManager.on('show.section', function (section) { if (options.sectionId === section.id) { $container.trigger('refresh.taotree'); } }); generisRouter.on('urichange', function(nodeUri, sectionId) { if (options.sectionId === sectionId) { $container.trigger('refresh.taotree', [{loadNode : uri.encode(nodeUri)}]); } }); }); }); } $container.on('ready.taotree', function() { resolve(); }); }); }; /** * Set tree state * @param treeState */ function setTreeState(treeState) { $container.data('tree-state', treeState); } /** * Check if a node has access to a type of action regarding it's permissions * @private * @param {String} actionType - in selectClass, selectInstance, moveInstance and delete * @param {Object} node - the node data as recevied from the server * @returns {Boolean} true if the action is allowed */ function hasAccessTo(actionType, node){ var action = options.actions[actionType]; if(node && action && node.permissions && action.rights){ return permissionsManager.isContextAllowed(action.rights, { uri : node.attributes['data-uri'], classUri : node.attributes['data-classUri'], id : node.attributes.id }); } return true; } /** * Check whether the nodes in a tree are selectable. If not, we add the private class. * @private * @param {Object} node - the tree node as recevied from the server */ function computeSelectionAccess(node){ if(_.isArray(node)){ _.forEach(node, computeSelectionAccess); return; } if(node.type){ addClassToNode(node, getPermissionClass(node)); if (!hasAccessTo('moveInstance', node)) { addClassToNode(node, 'node-undraggable'); } } if(node.children){ _.forEach(node.children, computeSelectionAccess); } } /** * Get the CSS class to apply to the node regarding the computed permissions * @private * @param {Object} node - the tree node * @returns {String} the CSS class */ function getPermissionClass(node){ var nodeId = node.attributes['data-uri']; var rights = permissionsManager.getRights(); var count = _.reduce(rights, function(acc, right){ if(permissionsManager.hasPermission(nodeId, right)){ acc++; } return acc; }, 0); if (rights.length === 0 || count === rights.length) { return 'permissions-full'; } if(count === 0){ return 'permissions-none'; } return 'permissions-partial'; } /** * Add a title attribute to the nodes * @private * @param {Object} node - the tree node as recevied from the server */ function addTitle(node){ if(_.isArray(node)){ _.forEach(node, addTitle); return; } if(node.attributes && node.data){ node.attributes.title = node.data; } if(node.children){ _.forEach(node.children, addTitle); } } function needMore(node){ if(_.isArray(node) && lastOpened && lastOpened.length && lastOpened.data('count') > pageRange){ node.push(moreNode); } else { if(node.count){ node.attributes['data-count'] = node.count; if (node.children && node.count > node.children.length) { node.children.push(moreNode); } } if(node.children){ _.forEach(node.children, needMore); } if(_.isArray(node)){ _.forEach(node, needMore); } } } function loadMore($node, $parentNode, tree){ var current = $parentNode.children('ul').children('li.node-instance').length; var count = $parentNode.data('count'); var left = count - current; var params = _.defaults({ 'classUri' : $parentNode.attr('id'), 'subclasses' : 0, 'offset' : current, 'limit' : left < 0 ? pageRange : (left < pageRange ? left : pageRange) }, serverParams); $.ajax(tree.settings.data.opts.url, { type : tree.settings.data.opts.method, dataType : tree.settings.data.type, async : tree.settings.data.async, data : params }).done(function(response){ var treeData = getTreeData(response); if(treeData && _.isArray(treeData.children)){ treeData = treeData.children; } if(_.isArray(treeData)){ _.forEach(treeData, function(newNode){ if(newNode.type === 'instance'){ //yes the server send also the class, even though I ask him gently... tree.create(newNode, $parentNode); } }); tree.deselect_branch($node); tree.remove($node); if(left - treeData.length > 0){ tree.create(moreNode, $parentNode); } } }); } /** * Function executes first found allowed action for tree node. * @param {object} actions - All tree actions * @param {object} [context] - Node context * @param {object} [context.permissions] - Node permissions * @param {object} [context.context] - The context of the action: (class|instance|resource|*) * @param {array} exclude - list of actions to be excluded. * @returns {undefined} */ function executePossibleAction(actions, nodeContext, exclude) { var possibleActions; if (!_.isArray(exclude)) { exclude = []; } possibleActions = _.filter(actions, function (action, name) { var possible = _.contains(nodeContext.context, action.context); return possible && !_.contains(exclude, name); }); //execute the first allowed action if(possibleActions.length > 0){ actionManager.exec(possibleActions[0], nodeContext); } } function addClassToNode(node, clazz){ if(node && node.attributes){ node.attributes['class'] = node.attributes['class'] || ''; if(node.attributes['class'].length) { node.attributes['class'] = node.attributes['class'] + ' ' + clazz; } else { node.attributes['class'] = clazz; } } } /** * Parse a response from a request to get the tree data * and extract the permissions if given * @param {Object} response - from a request * @returns {Object} the tree data */ function getTreeData(response){ var treeData = response.tree || response; var currentRights; if(response.permissions){ currentRights = permissionsManager.getRights(); if(response.permissions.supportedRights && response.permissions.supportedRights.length && currentRights.length === 0) { permissionsManager.setSupportedRights(response.permissions.supportedRights); } if(response.permissions.data){ permissionsManager.addPermissions(response.permissions.data); } } return treeData; } /** * @param {String} id * @param {Object} tree * * @returns {Boolean} Whether or not the selection succeed */ function selectNodeById(id, tree) { var $node; if (!id) { return false; } $node = $('#' + id, $container); if(!$node.length || $node.hasClass('private')){ return false; } tree.select_branch($node); return true; } return setUpTree(); } }; });