fix container multi select, no longer required to pause events on selectionmodel
[tine20] / tine20 / Tinebase / js / widgets / container / TreePanel.js
1 /*
2  * Tine 2.0
3  * 
4  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
5  * @author      Cornelius Weiss <c.weiss@metaways.de>
6  * @copyright   Copyright (c) 2007-2011 Metaways Infosystems GmbH (http://www.metaways.de)
7  */
8 Ext.ns('Tine.widgets', 'Tine.widgets.container');
9
10 /**
11  * @namespace   Tine.widgets.container
12  * @class       Tine.widgets.container.TreePanel
13  * @extends     Ext.tree.TreePanel
14  * @author      Cornelius Weiss <c.weiss@metaways.de>
15  * @param       {Object} config Configuration options
16  * @description
17  * <p>Utility class for generating container trees as used in the apps tree panel</p>
18  * <p>This widget handles all container related actions like add/rename/delte and manager permissions<p>
19  *<p>Example usage:</p>
20 <pre><code>
21 var taskPanel =  new Tine.containerTreePanel({
22     app: Tine.Tinebase.appMgr.get('Tasks'),
23     recordClass: Tine.Tasks.Model.Task
24 });
25 </code></pre>
26  */
27 Tine.widgets.container.TreePanel = function(config) {
28     Ext.apply(this, config);
29
30     this.addEvents(
31         /**
32          * @event containeradded
33          * Fires when a container was added
34          * @param {container} the new container
35          */
36         'containeradd',
37         /**
38          * @event containerdelete
39          * Fires when a container got deleted
40          * @param {container} the deleted container
41          */
42         'containerdelete',
43         /**
44          * @event containerrename
45          * Fires when a container got renamed
46          * @param {container} the renamed container
47          */
48         'containerrename',
49         /**
50          * @event containerpermissionchange
51          * Fires when a container got renamed
52          * @param {container} the container whose permissions where changed
53          */
54         'containerpermissionchange',
55         /**
56          * @event containercolorset
57          * Fires when a container color got changed
58          * @param {container} the container whose color where changed
59          */
60         'containercolorset'
61     );
62
63     Tine.widgets.container.TreePanel.superclass.constructor.call(this);
64 };
65
66 Ext.extend(Tine.widgets.container.TreePanel, Ext.tree.TreePanel, {
67     /**
68      * @cfg {Tine.Tinebase.Application} app
69      */
70     app: null,
71     /**
72      * @cfg {Boolean} allowMultiSelection (defaults to true)
73      */
74     allowMultiSelection: true,
75     /**
76      * @cfg {String} defaultContainerPath
77      */
78     defaultContainerPath: null,
79     /**
80      * @cfg {array} extraItems additional items to display under all
81      */
82     extraItems: null,
83     /**
84      * @cfg {String} filterMode one of:
85      *   - gridFilter: hooks into the grids.store
86      *   - filterToolbar: hooks into the filterToolbar (container filterModel required)
87      */
88     filterMode: 'gridFilter',
89     /**
90      * @cfg {Tine.data.Record} recordClass
91      */
92     recordClass: null,
93     /**
94      * @cfg {Array} requiredGrants
95      * grants which are required to select leaf node(s)
96      */
97     requiredGrants: null,
98
99     /**
100      * @cfg {Boolean} useContainerColor
101      * use container colors
102      */
103     useContainerColor: false,
104     /**
105      * @cfg {Boolean} useProperties
106      * use container properties
107      */
108     useProperties: true,
109
110     /**
111      * @property {Object}
112      * modelConfiguration of recordClass (if available)
113      */
114     modelConfiguration: null,
115
116     /**
117      * @cfg {String}
118      * canonical name
119      */
120     canonicalName: 'ContainerTree',
121
122     /**
123      * Referenced grid panel
124      */
125     gridPanel: null,
126
127     useArrows: true,
128     border: false,
129     autoScroll: true,
130     enableDrop: true,
131     ddGroup: 'containerDDGroup',
132     hasPersonalContainer: true,
133     hasContextMenu: true,
134
135     /**
136      * @fixme not needed => all events hand their events over!!!
137      *
138      * @property ctxNode holds treenode which got a contextmenu
139      * @type Ext.tree.TreeNode
140      */
141     ctxNode: null,
142
143     /**
144      * No user interactions, menus etc. allowed except for browsing
145      */
146     readOnly: false,
147
148     /**
149      * init this treePanel
150      */
151     initComponent: function() {
152         if (! this.appName && this.recordClass) {
153             this.appName = this.recordClass.getMeta('appName');
154         }
155         if (! this.app) {
156             this.app = Tine.Tinebase.appMgr.get(this.appName);
157         }
158
159         if (this.allowMultiSelection) {
160             this.selModel = new Ext.tree.MultiSelectionModel({});
161         }
162
163         if (this.recordClass) {
164             this.modelConfiguration = this.recordClass.getModelConfiguration();
165         }
166
167         if (this.modelConfiguration) {
168             this.hasPersonalContainer = this.modelConfiguration.hasPersonalContainer !== false;
169         }
170
171         var containerName = this.recordClass ? this.recordClass.getContainerName() : 'container';
172         var containersName = this.recordClass ? this.recordClass.getContainersName() : 'containers';
173
174         //ngettext('container', 'containers', n);
175         this.containerName = this.containerName || this.app.i18n.n_hidden(containerName, containersName, 1);
176         this.containersName = this.containersName || this.app.i18n._hidden(containersName);
177
178         this.loader = this.loader || new Tine.widgets.tree.Loader({
179             getParams: this.onBeforeLoad.createDelegate(this),
180             inspectCreateNode: this.onBeforeCreateNode.createDelegate(this)
181         });
182
183         this.loader.on('virtualNodesSelected', this.onVirtualNodesSelected.createDelegate(this));
184
185         var extraItems = this.getExtraItems();
186         this.root = this.getRoot(extraItems);
187         if (!this.hasPersonalContainer && ! extraItems.length) {
188             this.rootVisible = false;
189         }
190
191         if (!this.readOnly && !this.dropConfig) {
192             // init drop zone
193             this.dropConfig = {
194                 ddGroup: this.ddGroup || 'TreeDD',
195                 appendOnly: this.ddAppendOnly === true,
196                 /**
197                  * @todo check acl!
198                  */
199                 onNodeOver: function (n, dd, e, data) {
200                     var node = n.node;
201
202                     // auto node expand check
203                     if (node.hasChildNodes() && !node.isExpanded()) {
204                         this.queueExpand(node);
205                     }
206                     return node.attributes.allowDrop ? 'tinebase-tree-drop-move' : false;
207                 },
208                 isValidDropPoint: function (n, dd, e, data) {
209                     return n.node.attributes.allowDrop;
210                 },
211                 completeDrop: Ext.emptyFn
212             };
213         }
214
215         if (this.hasContextMenu) {
216             this.initContextMenu();
217         }
218
219         this.getSelectionModel().on('beforeselect', this.onBeforeSelect, this);
220         this.getSelectionModel().on('selectionchange', this.onSelectionChange, this);
221
222         this.on('click', this.onClick, this);
223         if (this.hasContextMenu) {
224             this.on('contextmenu', this.onContextMenu, this);
225         }
226
227         if (!this.readOnly) {
228             this.on('beforenodedrop', this.onBeforeNodeDrop, this);
229             this.on('append', this.onAppendNode, this);
230             this.on('beforecollapsenode', this.onBeforeCollapse, this);
231         }
232
233         Tine.widgets.container.TreePanel.superclass.initComponent.call(this);
234     },
235
236     /**
237      * @param nodes
238      */
239     onVirtualNodesSelected: function (nodes) {
240         this.suspendEvents();
241
242         if (0 === nodes.length) {
243             return;
244         }
245
246
247         var sm = this.getSelectionModel();
248
249         sm.clearSelections(true);
250
251         for (var i = 0; i < nodes.length; i++) {
252             var node = nodes[i];
253
254             if (sm.isSelected(node)) {
255                 sm.lastSelNode = node;
256                 continue;
257             }
258
259             sm.selNodes.push(node);
260             sm.selMap[node.id] = node;
261             sm.lastSelNode = node;
262             node.ui.onSelectedChange(true);
263         }
264
265         this.onFilterChange();
266         this.resumeEvents();
267     },
268
269     /**
270      * returns canonical path part
271      * @returns {string}
272      */
273     getCanonicalPathSegment: function () {
274         if (this.recordClass) {
275             return [
276                 this.recordClass.getMeta('modelName'),
277                 this.canonicalName,
278             ].join(Tine.Tinebase.CanonicalPath.separator);
279         }
280     },
281
282     getRoot: function(extraItems)
283     {
284         return {
285             path: '/',
286             cls: 'tinebase-tree-hide-collapsetool',
287             expanded: true,
288             children: [{
289                 path: Tine.Tinebase.container.getMyNodePath(),
290                 id: 'personal',
291                 hidden: !this.hasPersonalContainer
292             }, {
293                 path: '/shared',
294                 id: 'shared'
295             }, {
296                 path: '/personal',
297                 id: 'otherUsers',
298                 hidden: !this.hasPersonalContainer
299             }].concat(extraItems)
300         };
301     },
302
303     /**
304      * template fn for subclasses to set default path
305      *
306      * @return {String}
307      */
308     getDefaultContainerPath: function() {
309         return this.defaultContainerPath || '/';
310     },
311
312     /**
313      * template fn for subclasses to append extra items
314      *
315      * @return {Array}
316      */
317     getExtraItems: function() {
318         return this.extraItems || [];
319     },
320
321     /**
322      * returns a filter plugin to be used in a grid
323      */
324     getFilterPlugin: function() {
325         if (!this.filterPlugin) {
326             this.filterPlugin = new Tine.widgets.tree.FilterPlugin({
327                 treePanel: this
328             });
329         }
330
331         return this.filterPlugin;
332     },
333
334     /**
335      * returns object of selected container/filter or null/default
336      *
337      * @param {Array} [requiredGrants]
338      * @param {Tine.Tinebase.Model.Container} [defaultContainer]
339      * @param {Boolean} onlySingle use default if more than one container in selection
340      * @return {Tine.Tinebase.Model.Container}
341      */
342     getSelectedContainer: function(requiredGrants, defaultContainer, onlySingle) {
343         var container = defaultContainer,
344             sm = this.getSelectionModel(),
345             selection = typeof sm.getSelectedNodes == 'function' ? sm.getSelectedNodes() : [sm.getSelectedNode()];
346
347         if (Ext.isArray(selection) && selection.length > 0 && (! onlySingle || selection.length === 1 || ! container)) {
348             container = this.getContainerFromSelection(selection, requiredGrants) || container;
349         }
350         // postpone this as we don't get the whole container record here
351 //        else if (this.filterMode == 'filterToolbar' && this.filterPlugin) {
352 //            container = this.getContainerFromFilter() || container;
353 //        }
354
355         return container;
356     },
357
358     /**
359      * get container from selection
360      *
361      * @param {Array} selection
362      * @param {Array} requiredGrants
363      * @return {Tine.Tinebase.Model.Container}
364      */
365     getContainerFromSelection: function(selection, requiredGrants) {
366         var result = null;
367
368         Ext.each(selection, function(node) {
369             if (node && Tine.Tinebase.container.pathIsContainer(node.attributes.container.path)) {
370                 if (! requiredGrants || this.hasGrant(node, requiredGrants)) {
371                     result = node.attributes.container;
372                     // take the first one
373                     return false;
374                 }
375             }
376         }, this);
377
378         return result;
379     },
380
381     /**
382      * get container from filter toolbar
383      *
384      * @param {Array} requiredGrants
385      * @return {Tine.Tinebase.Model.Container}
386      *
387      * TODO make this work -> atm we don't get the account grants here (why?)
388      */
389     getContainerFromFilter: function(requiredGrants) {
390         var result = null;
391
392         // check if single container is selected in filter toolbar 
393         var ftb = this.filterPlugin.getGridPanel().filterToolbar,
394             filterValue = null;
395
396         ftb.filterStore.each(function(filter) {
397             if (filter.get('field') == this.recordClass.getMeta('containerProperty')) {
398                 filterValue = filter.get('value');
399                 if (filter.get('operator') == 'equals') {
400                     result = filterValue;
401                 } else if (filter.get('operator') == 'in' && filterValue.length == 1){
402                     result = filterValue[0];
403                 }
404                 // take the first one
405                 return false;
406             }
407         }, this);
408
409         return result;
410     },
411
412     /**
413      * convert containerPath to treePath
414      *
415      * @param {String}  containerPath
416      * @return {String} treePath
417      */
418     getTreePath: function(containerPath) {
419         var treePath = '/' + this.getRootNode().id + (containerPath !== '/' ? containerPath : '');
420
421         // replace personal with otherUsers if personal && ! personal/myaccountid
422         var matches = containerPath.match(/^\/personal\/{0,1}([0-9a-z_\-]*)\/{0,1}/i);
423         if (matches) {
424             if (matches[1] != Tine.Tinebase.registry.get('currentAccount').accountId) {
425                 treePath = treePath.replace('personal', 'otherUsers');
426             } else {
427                 treePath = treePath.replace('personal/'  + Tine.Tinebase.registry.get('currentAccount').accountId, 'personal');
428             }
429         }
430
431         return treePath;
432     },
433
434     /**
435      * checkes if user has requested grant for given container represented by a tree node
436      *
437      * @param {Ext.tree.TreeNode} node
438      * @param {Array} grant
439      * @return {}
440      */
441     hasGrant: function(node, grants) {
442         var attr = node.attributes,
443             condition = false;
444
445         if(attr && attr.leaf) {
446             condition = true;
447             Ext.each(grants, function(grant) {
448                 condition = condition && attr.container.account_grants[grant];
449             }, this);
450         }
451
452         return condition;
453     },
454
455     /**
456      * @private
457      * - select default path
458      */
459     afterRender: function() {
460         Tine.widgets.container.TreePanel.superclass.afterRender.call(this);
461
462         var defaultContainerPath = this.getDefaultContainerPath();
463
464         if (defaultContainerPath && defaultContainerPath != '/') {
465             var root = '/' + this.getRootNode().id;
466
467             this.expand();
468
469             // @TODO use getTreePath() when filemanager is fixed
470             (function() {
471                 // no initial load triggering here
472                 this.getSelectionModel().suspendEvents();
473                 this.selectPath(root + defaultContainerPath);
474                 this.getSelectionModel().resumeEvents();
475             }).defer(100, this);
476         }
477     },
478
479     /**
480      * @private
481      */
482     initContextMenu: function() {
483
484         this.contextMenuUserFolder = Tine.widgets.tree.ContextMenu.getMenu({
485             nodeName: this.containerName,
486             actions: ['add'],
487             scope: this,
488             backend: 'Tinebase_Container',
489             backendModel: 'Container'
490         });
491
492         this.contextMenuSingleContainer = Tine.widgets.tree.ContextMenu.getMenu({
493             nodeName: this.containerName,
494             actions: ['delete', 'rename', 'grants'].concat(
495                 this.useProperties ? ['properties'] : []
496             ).concat(
497                 this.useContainerColor ? ['changecolor'] : []
498             ),
499             scope: this,
500             backend: 'Tinebase_Container',
501             backendModel: 'Container'
502         });
503
504         this.contextMenuSingleContainerProperties = Tine.widgets.tree.ContextMenu.getMenu({
505             nodeName: this.containerName,
506             actions: ['properties'],
507             scope: this,
508             backend: 'Tinebase_Container',
509             backendModel: 'Container'
510         });
511     },
512
513     /**
514      * called when node is appended to this tree
515      */
516     onAppendNode: function(tree, parent, appendedNode, idx) {
517         if (appendedNode.leaf && this.hasGrant(appendedNode, this.requiredGrants)) {
518             if (this.useContainerColor) {
519                 appendedNode.ui.render = appendedNode.ui.render.createSequence(function () {
520                     this.colorNode = Ext.DomHelper.insertAfter(this.iconNode, {
521                         tag: 'span',
522                         html: '&nbsp;&#9673;&nbsp',
523                         style: {color: appendedNode.attributes.container.color || '#808080'}
524                     }, true);
525                 }, appendedNode.ui);
526             }
527         }
528     },
529
530     /**
531      * expand automatically on node click
532      *
533      * @param {} node
534      * @param {} e
535      */
536     onClick: function(node, e) {
537         var sm = this.getSelectionModel(),
538             selectedNode = sm.getSelectedNode();
539
540         // NOTE: in single select mode, a node click on a selected node does not trigger 
541         //       a selection change. We need to do this by hand here
542         if (! this.allowMultiSelection && node == selectedNode) {
543             this.onSelectionChange(sm, node);
544         }
545
546         node.expand();
547     },
548
549     /**
550      * show context menu
551      *
552      * @param {} node
553      * @param {} event
554      */
555     onContextMenu: function(node, event) {
556         this.ctxNode = node;
557         var container = node.attributes.container,
558             path = container.path,
559             owner;
560
561         if (! Ext.isString(path)) {
562             return;
563         }
564
565         event.stopPropagation();
566         event.preventDefault();
567
568         if (Tine.Tinebase.container.pathIsContainer(path)) {
569             if (container.account_grants && container.account_grants.adminGrant) {
570                 this.contextMenuSingleContainer.showAt(event.getXY());
571             } else {
572                 this.contextMenuSingleContainerProperties.showAt(event.getXY());
573             }
574         } else if (path.match(/^\/shared$/) && (Tine.Tinebase.common.hasRight('admin', this.app.appName) || Tine.Tinebase.common.hasRight('manage_shared_folders', this.app.appName))){
575             this.contextMenuUserFolder.showAt(event.getXY());
576         } else if (Tine.Tinebase.registry.get('currentAccount').accountId == Tine.Tinebase.container.pathIsPersonalNode(path)){
577             this.contextMenuUserFolder.showAt(event.getXY());
578         }
579     },
580
581     /**
582      * adopt attr
583      *
584      * @param {Object} attr
585      */
586     onBeforeCreateNode: function(attr) {
587         if (attr.accountDisplayName) {
588             attr.name = attr.accountDisplayName;
589             attr.path = '/personal/' + attr.accountId;
590             attr.id = attr.accountId;
591         }
592
593         if (! attr.name && attr.path) {
594             attr.name = Tine.Tinebase.container.path2name(attr.path, this.containerName, this.containersName);
595         }
596
597         Ext.applyIf(attr, {
598             text: Ext.util.Format.htmlEncode(attr.name),
599             qtip: Tine.Tinebase.common.doubleEncode(attr.name),
600             leaf: !!attr.account_grants,
601             allowDrop: !!attr.account_grants && attr.account_grants.addGrant
602         });
603
604         // copy 'real' data to container space
605         attr.container = Ext.copyTo({}, attr, Tine.Tinebase.Model.Container.getFieldNames());
606     },
607
608     /**
609      * returns params for async request
610      *
611      * @param {Ext.tree.TreeNode} node
612      * @return {Object}
613      */
614     onBeforeLoad: function(node) {
615         var path = node.attributes.path;
616         var type = Tine.Tinebase.container.path2type(path);
617         var owner = Tine.Tinebase.container.pathIsPersonalNode(path);
618
619         if (type === 'personal' && ! owner) {
620             type = 'otherUsers';
621         }
622
623         var params = {
624             method: 'Tinebase_Container.getContainer',
625             application: this.app.appName,
626             containerType: type,
627             requiredGrants: this.requiredGrants,
628             owner: owner
629         };
630
631         return params;
632     },
633
634     /**
635      * permit selection of nodes with missing required grant
636      *
637      * @param {} sm
638      * @param {} newSelection
639      * @param {} oldSelection
640      * @return {Boolean}
641      */
642     onBeforeSelect: function(sm, newSelection, oldSelection) {
643
644         if (this.requiredGrant && newSelection.isLeaf()) {
645             var accountGrants =  newSelection.attributes.container.account_grants || {};
646             if (! accountGrants[this.requiredGrant]) {
647                 var message = '<b>' +String.format(i18n._("You are not allowed to select the {0} '{1}':"), this.containerName, newSelection.attributes.text) + '</b><br />' +
648                               String.format(i18n._("{0} grant is required for desired action"), this.requiredGrant);
649                 Ext.Msg.alert(i18n._('Insufficient Grants'), message);
650                 return false;
651             }
652         }
653     },
654
655     /**
656      * record got dropped on container node
657      *
658      * @param {Object} dropEvent
659      * @private
660      *
661      * TODO use Ext.Direct
662      */
663     onBeforeNodeDrop: function(dropEvent) {
664         var targetContainerId = dropEvent.target.id;
665
666         // get selection filter from grid
667         var sm = this.app.getMainScreen().getCenterPanel().getGrid().getSelectionModel();
668         if (sm.getCount() === 0) {
669             return false;
670         }
671         var filter = sm.getSelectionFilter();
672
673         // move messages to folder
674         Ext.Ajax.request({
675             params: {
676                 method: 'Tinebase_Container.moveRecordsToContainer',
677                 targetContainerId: targetContainerId,
678                 filterData: filter,
679                 model: this.recordClass.getMeta('modelName'),
680                 applicationName: this.recordClass.getMeta('appName')
681             },
682             scope: this,
683             success: function(result, request){
684                 // update grid
685                 this.app.getMainScreen().getCenterPanel().loadGridData();
686             }
687         });
688
689         // prevent repair actions
690         dropEvent.dropStatus = true;
691         return true;
692     },
693
694     /**
695      * require reload when node is collapsed
696      */
697     onBeforeCollapse: function(node) {
698         node.removeAll();
699         node.loaded = false;
700     },
701
702     onFilterChange: function() {
703         // get filterToolbar
704         var ftb = this.filterPlugin.getGridPanel().filterToolbar;
705
706         // in case of filterPanel
707         ftb = ftb.activeFilterPanel ? ftb.activeFilterPanel : ftb;
708
709         // remove all ftb container and /toberemoved/ filters
710         ftb.supressEvents = true;
711         ftb.filterStore.each(function(filter) {
712             var field = filter.get('field');
713             // @todo find criteria what to remove
714             if (field === 'container_id' || field === 'attender' || field === 'path') {
715                 ftb.deleteFilter(filter);
716             }
717         }, this);
718         ftb.supressEvents = false;
719
720         // set ftb filters according to tree selection
721         var containerFilter = this.getFilterPlugin().getFilter();
722         ftb.addFilter(new ftb.record(containerFilter));
723
724         ftb.onFiltertrigger();
725     },
726
727     /**
728      * called when tree selection changes
729      *
730      * @param {} sm
731      * @param {} nodes
732      */
733     onSelectionChange: function(sm, nodes) {
734
735         if (this.filterMode == 'gridFilter' && this.filterPlugin) {
736             this.filterPlugin.onFilterChange();
737         }
738         if (this.filterMode == 'filterToolbar' && this.filterPlugin) {
739
740             this.onFilterChange();
741
742             // finally select the selected node, as filtertrigger clears all selections
743             sm.suspendEvents();
744             Ext.each(nodes, function(node) {
745                 sm.select(node, Ext.EventObject, true);
746             }, this);
747             sm.resumeEvents();
748         }
749     },
750
751     /**
752      * selects path by container Path
753      *
754      * @param {String} containerPath
755      * @param {String} [attr]
756      * @param {Function} [callback]
757      */
758     selectContainerPath: function(containerPath, attr, callback) {
759         return this.selectPath(this.getTreePath(containerPath), attr, callback);
760     },
761
762     /**
763      * get default container for new records
764      *
765      * @param {String} default container registry key
766      * @return {Tine.Tinebase.Model.Container}
767      */
768     getDefaultContainer: function(registryKey) {
769         if (! registryKey) {
770             registryKey = 'defaultContainer';
771         }
772
773         var container = Tine[this.appName].registry.get(registryKey);
774
775         return this.getSelectedContainer('addGrant', container, true);
776     }
777 });