bb236a00037832960685c9ec3c181d24a2d282d2
[tine20] / tine20 / Tinebase / js / widgets / dialog / DuplicateResolveGridPanel.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-2013 Metaways Infosystems GmbH (http://www.metaways.de)
7  */
8 Ext.ns('Tine.widgets.dialog');
9
10 /**
11  * 
12  * @namespace   Tine.widgets.dialog
13  * @class       Tine.widgets.dialog.DuplicateResolveGridPanel
14  * @extends     Ext.grid.EditorGridPanel
15  * @author      Cornelius Weiss <c.weiss@metaways.de>
16  * @constructor
17  * @param {Object} config The configuration options.
18  */
19 Tine.widgets.dialog.DuplicateResolveGridPanel = Ext.extend(Ext.grid.EditorGridPanel, {
20     /**
21      * @cfg {Tine.Tinebase.Application} app
22      * instance of the app object (required)
23      */
24     app: null,
25
26     // private config overrides
27     cls: 'tw-editdialog',
28     border: false,
29     layout: 'fit',
30     enableColumnMove: false,
31     stripeRows: true,
32     trackMouseOver: false,
33     clicksToEdit:2,
34     enableHdMenu : false,
35     viewConfig : {
36         forceFit:true
37     },
38
39     initComponent: function() {
40
41         this.title = _('The record you try to add might already exist.');
42
43         this.view = new Ext.grid.GroupingView({
44             forceFit:true,
45             hideGroupedColumn: true,
46             groupTextTpl: '{group}'
47         });
48
49         this.initColumnModel();
50         this.initToolbar();
51
52         this.store.on('load', this.onStoreLoad, this);
53         this.store.on('strategychange', this.onStoreLoad, this);
54         this.on('cellclick', this.onCellClick, this);
55         this.on('afterrender', this.onAfterRender, this);
56         this.on('afteredit', this.onAfterEdit, this);
57
58         Tine.widgets.dialog.DuplicateResolveGridPanel.superclass.initComponent.call(this);
59     },
60
61     onAfterRender: function() {
62         // apply initial strategy
63         this.onStoreLoad();
64     },
65
66     /**
67      * is called on doubleclick on a cell and sets the editor if the cell is the finalValue
68      * 
69      * @param {Ext.grid.EditorGridPanel} grid
70      * @param {number} rowIndex
71      * @param {number} colIndex
72      * @param {Ext.EventObject} e
73      */
74     onCellDblClick: function(grid, rowIndex, colIndex, e) {
75         var strategy = this.actionCombo.getValue();
76         
77         // editing is just possible if strategy "keep" is chosen, or the finalValue when choosing strategy "mergeTheirs" or "mergeMine"
78         if (! (((colIndex == 4) && (strategy == 'mergeMine' || strategy == 'mergeTheirs')) 
79             || (strategy == 'keep' && (colIndex == 2)))) {
80             return;
81         }
82         
83         var cm = this.getColumnModel();
84         var column = cm.getColumnAt(colIndex);
85         var record = this.store.getAt(rowIndex);
86         
87         var fieldDef = record.get('fieldDef');
88         
89         if (! Ext.isFunction(fieldDef.type)) {
90             switch (fieldDef.type) {
91                 case 'string':
92                     column.setEditor(new Ext.form.TextField({}));
93                     break;
94                 case 'date':
95                     column.setEditor(new Ext.ux.form.ClearableDateField({}));
96                     break;
97                 case 'int':
98                     column.setEditor(new Ext.form.NumberField({}));
99                     break;
100                 default:
101                     // TODO: allow more types, create FieldRegistry
102                     return;
103             }
104             this.startEditing(rowIndex, colIndex);
105         } else {
106             return;
107         }
108     },
109     
110     /**
111      * is called after edit a field
112      * 
113      * @param {Ext.EventObject} e
114      */
115     onAfterEdit: function(e) {
116         var record = this.store.getAt(e.row);
117         if (e.field == 'clientValue') {
118             record.set('finalValue', e.value);
119         }
120         this.stopEditing();
121     },
122     
123     /**
124      * adopt final value to the one selected
125      * 
126      * @param {Ext.grid.EditorGridPanel} grid
127      * @param {number} rowIndex
128      * @param {number} colIndex
129      * @param {Ext.EventObject} e
130      */
131     onCellClick: function(grid, rowIndex, colIndex, e) {
132         var dataIndex = this.getColumnModel().getDataIndex(colIndex),
133             resolveRecord = this.store.getAt(rowIndex);
134
135         if (resolveRecord && dataIndex && dataIndex.match(/clientValue|value\d+/)) {
136             resolveRecord.set('finalValue', resolveRecord.get(dataIndex));
137
138             var celEl = this.getView().getCell(rowIndex, this.getColumnModel().getIndexById('finalValue'));
139             if (celEl) {
140                 Ext.fly(celEl).highlight();
141             }
142         }
143     },
144
145     /**
146      * called when the store got new data
147      */
148     onStoreLoad: function() {
149         var strategy = this.store.resolveStrategy;
150
151         this.actionCombo.setValue(strategy);
152         this.applyStrategy(strategy);
153     },
154
155     /**
156      * select handler of action combo
157      */
158     onActionSelect: function(combo, record, idx) {
159         var strategy = record.get('value');
160
161         this.applyStrategy(strategy);
162         this.store.applyStrategy(strategy);
163     },
164
165     /**
166      * apply an action (generate final data)
167      * - mergeTheirs:   merge keep existing values (discards client record)
168      * - mergeMine:     merge, keep client values (discards client record)
169      * - discard:       discard client record
170      * - keep:          keep client record (create duplicate)
171      * 
172      * @param {Ext.data.Store} store with field records (DuplicateResolveModel)
173      * @param {Sting} strategy
174      */
175     applyStrategy: function(strategy) {
176         var cm = this.getColumnModel(),
177             view = this.getView();
178
179         if (cm) {
180             cm.setHidden(cm.getIndexById('clientValue'), strategy == 'discard');
181             cm.setHidden(cm.getIndexById('finalValue'), strategy == 'keep');
182
183             if (view && view.grid) {
184                 this.getView().refresh();
185             }
186         }
187     },
188     
189     /**
190      * init our column model
191      */
192     initColumnModel: function() {
193         var valueRendererDelegate = this.valueRenderer.createDelegate(this);
194
195         this.cm = new Ext.grid.ColumnModel([{
196             header: _('Field Group'), 
197             width:50, 
198             sortable: true, 
199             dataIndex:'group', 
200             id: 'group', 
201             menuDisabled:true
202         }, {
203             header: _('Field Name'), 
204             width:50, 
205             sortable: true, 
206             dataIndex:'i18nFieldName', 
207             id: 'i18nFieldName', 
208             menuDisabled:true
209         }, {
210             header: _('My Value'), 
211             width:50, 
212             resizable:false, 
213             dataIndex: 'clientValue', 
214             id: 'clientValue', 
215             menuDisabled:true, 
216             renderer: valueRendererDelegate
217         }, {
218             header: _('Existing Value'), 
219             width:50, 
220             resizable:false, 
221             dataIndex: 'value' + this.store.duplicateIdx, 
222             id: 'value' + this.store.duplicateIdx, 
223             menuDisabled:true, 
224             renderer: valueRendererDelegate
225         }, {
226             header: _('Final Value'), 
227             width:50, 
228             resizable:false, 
229             dataIndex: 'finalValue', 
230             id: 'finalValue', 
231             menuDisabled:true,
232             renderer: valueRendererDelegate,
233             editable: true
234         }]);
235     },
236
237     /**
238      * init the toolbar
239      */
240     initToolbar: function() {
241         this.tbar = [{
242             xtype: 'label',
243             text: _('Action:') + ' '
244         }, {
245             xtype: 'combo',
246             ref: '../actionCombo',
247             typeAhead: true,
248             width: 250,
249             triggerAction: 'all',
250             lazyRender:true,
251             mode: 'local',
252             valueField: 'value',
253             displayField: 'text',
254             value: this.store.resolveStrategy,
255             store: new Ext.data.ArrayStore({
256                 id: 0,
257                 fields: ['value', 'text'],
258                 data: [
259                     ['mergeTheirs', _('Merge, keeping existing details')],
260                     ['mergeMine',   _('Merge, keeping my details')],
261                     ['discard',     _('Keep existing record and discard mine')],
262                     ['keep',        _('Keep both records')]
263                 ]
264             }),
265             listeners: {
266                 scope: this, 
267                 select: this.onActionSelect
268             }
269         }];
270     },
271
272     /**
273      * interceptor for all renderers
274      * - manage colors
275      * - pick appropriate renderer
276      */
277     valueRenderer: function(value, metaData, record, rowIndex, colIndex, store) {
278         var fieldName = record.get('fieldName'),
279             dataIndex = this.getColumnModel().getDataIndex(colIndex),
280             renderer = Tine.widgets.grid.RendererManager.get(this.app, this.store.recordClass, fieldName, Tine.widgets.grid.RendererManager.CATEGORY_GRIDPANEL);
281
282         // color management
283         if (dataIndex && dataIndex.match(/clientValue|value\d+/) && !this.store.resolveStrategy.match(/(keep|discard)/)) {
284             var action = record.get('finalValue') == value ? 'keep' : 'discard';
285             metaData.css = 'tine-duplicateresolve-' + action + 'value';
286         }
287         
288         return renderer.apply(this, arguments);
289     }
290 });
291
292 /**
293  * @class Tine.widgets.dialog.DuplicateResolveModel
294  * A specific {@link Ext.data.Record} type that represents a field/clientValue/doublicateValues/finalValue set and is made to work with the
295  * {@link Tine.widgets.dialog.DuplicateResolveGridPanel}.
296  * @constructor
297  */
298 Tine.widgets.dialog.DuplicateResolveModel = Ext.data.Record.create([
299     {name: 'fieldName', type: 'string'},
300     {name: 'fieldDef', type: 'fieldDef'},
301     {name: 'group', type: 'string'},
302     {name: 'i18nFieldName', type: 'string'},
303     'clientValue', 'value0' , 'value1' , 'value2' , 'value3' , 'value4', 'finalValue'
304 ]);
305
306 Tine.widgets.dialog.DuplicateResolveStore = Ext.extend(Ext.data.GroupingStore, {
307     /**
308      * @cfg {Tine.Tinebase.Application} app
309      * instance of the app object (required)
310      */
311     app: null,
312
313     /**
314      * @cfg {Ext.data.Record} recordClass
315      * record definition class  (required)
316      */
317     recordClass: null,
318
319     /**
320      * @cfg {Ext.data.DataProxy} recordProxy
321      */
322     recordProxy: null,
323
324     /**
325      * @cfg {Object/Record} clientRecord
326      */
327     clientRecord: null,
328
329     /**
330      * @cfg {Array} duplicates
331      * array of Objects or Records
332      */
333     duplicates: null,
334
335     /**
336      * @cfg {String} resolveStrategy
337      * default resolve action
338      */
339     resolveStrategy: null,
340
341     /**
342      * @cfg {String} defaultResolveStrategy
343      * default resolve action
344      */
345     defaultResolveStrategy: 'mergeTheirs',
346
347     // private config overrides
348     idProperty: 'fieldName',
349     fields: Tine.widgets.dialog.DuplicateResolveModel,
350
351     groupField: 'group',
352 //    groupOnSort: true,
353 //    remoteGroup: false,
354     sortInfo: {field: 'group', oder: 'ASC'},
355
356     constructor: function(config) {
357         var initialData = config.data;
358         delete config.data;
359
360         this.reader = new Ext.data.JsonReader({
361             idProperty: this.idProperty,
362             fields: this.fields
363         });
364
365         Tine.widgets.dialog.DuplicateResolveStore.superclass.constructor.apply(this, arguments);
366
367         if (! this.recordProxy && this.recordClass) {
368             this.recordProxy = new Tine.Tinebase.data.RecordProxy({
369                 recordClass: this.recordClass
370             });
371         }
372
373         // forece dublicate 0 atm.
374         this.duplicateIdx = 0;
375
376         if (initialData) {
377             this.loadData(initialData);
378         }
379     },
380
381     loadData: function(data, resolveStrategy, finalRecord) {
382         // init records
383         this.clientRecord = this.createRecord(data.clientRecord);
384
385         this.duplicates = data.duplicates;
386         Ext.each([].concat(this.duplicates), function(duplicate, idx) {this.duplicates[idx] = this.createRecord(this.duplicates[idx]);}, this);
387
388         this.resolveStrategy = resolveStrategy || this.defaultResolveStrategy;
389
390         if (finalRecord) {
391             finalRecord = this.createRecord(finalRecord);
392         }
393
394         var fieldDefinitions = this.recordClass.getFieldDefinitions(),
395             cfDefinitions = Tine.widgets.customfields.ConfigManager.getConfigs(this.app, this.recordClass, true);
396
397         var recordsToAdd = [];
398         Ext.each(fieldDefinitions.concat(cfDefinitions), function(field) {
399             
400             if (field.omitDuplicateResolving) {
401                 return;
402             }
403
404             var fieldName = field.name,
405                 fieldGroup = field.uiconfig ? field.uiconfig.group : field.group,
406                 recordData = {
407                     fieldName: fieldName,
408                     fieldDef: field,
409                     i18nFieldName: field.label ? this.app.i18n._hidden(field.label) : this.app.i18n._hidden(fieldName),
410                     clientValue: Tine.Tinebase.common.assertComparable(this.clientRecord.get(fieldName))
411                 };
412
413             recordData.group = fieldGroup ? this.app.i18n._hidden(fieldGroup) : recordData.i18nFieldName;
414             Ext.each([].concat(this.duplicates), function(duplicate, idx) {recordData['value' + idx] =  Tine.Tinebase.common.assertComparable(this.duplicates[idx].get(fieldName));}, this);
415
416             var record = new Tine.widgets.dialog.DuplicateResolveModel(recordData, fieldName);
417
418             if (finalRecord) {
419                 if (finalRecord.modified && finalRecord.modified.hasOwnProperty(fieldName)) {
420 //                    Tine.log.debug('Tine.widgets.dialog.DuplicateResolveStore::loadData ' + fieldName + 'changed from  ' + finalRecord.modified[fieldName] + ' to ' + finalRecord.get(fieldName));
421                     record.set('finalValue', finalRecord.modified[fieldName]);
422
423                 }
424
425                 record.set('finalValue', finalRecord.get(fieldName));
426             }
427
428             recordsToAdd.push(record);
429         }, this);
430
431         this.insert(0, recordsToAdd);
432         
433         if (! finalRecord) {
434             this.applyStrategy(this.resolveStrategy);
435         }
436
437         this.sortData();
438         this.fireEvent('load', this);
439     },
440
441     /**
442      * custom sorter
443      * 
444      * @param {String} f (ignored atm.)
445      * @param {String} direction
446      */
447     sortData: function(f, direction) {
448         direction = direction || 'ASC';
449         var groupConflictScore = {};
450             
451         this.each(function(r) {
452             var group = r.get('group'),
453                 myValue = String(r.get('clientValue')).replace(/^undefined$|^null$|^\[\]$/, ''),
454                 theirValue = String(r.get('value' + this.duplicateIdx)).replace(/^undefined$|^null$|^\[\]$/, '');
455             
456             if (! groupConflictScore.hasOwnProperty(group)) {
457                 groupConflictScore[group] = 990;
458             }
459             
460             if (myValue || theirValue) {
461                 groupConflictScore[group] -= 1;
462             }
463             
464             if (myValue != theirValue) {
465                 groupConflictScore[group] -= 10;
466             }
467             
468         }, this);
469         
470         this.data.sort('ASC', function(r1, r2) {
471             var g1 = r1.get('group'),
472                 v1 = String(groupConflictScore[g1]) + g1,
473                 g2 = r2.get('group'),
474                 v2 = String(groupConflictScore[g2]) + g2;
475                 
476             return v1 > v2 ? 1 : (v1 < v2 ? -1 : 0);
477         });
478     },
479
480     /**
481      * apply an strategy (generate final data)
482      * - mergeTheirs:   merge keep existing values (discards client record)
483      * - mergeMine:     merge, keep client values (discards client record)
484      * - discard:       discard client record
485      * - keep:          keep client record (create duplicate)
486      * 
487      * @param {Sting} strategy
488      */
489     applyStrategy: function(strategy) {
490         Tine.log.debug('Tine.widgets.dialog.DuplicateResolveStore::applyStrategy() - action: ' + strategy);
491         
492         this.resolveStrategy = strategy;
493         this.checkEditGrant();
494
495         this.each(function(resolveRecord) {
496             var theirs = resolveRecord.get('value' + this.duplicateIdx),
497                 mine = resolveRecord.get('clientValue'),
498                 location = this.resolveStrategy === 'keep' ? 'mine' : 'theirs';
499
500             // undefined or empty theirs value -> keep mine
501             if (this.resolveStrategy == 'mergeTheirs' && ['', 'null', 'undefined', '[]'].indexOf(String(theirs)) > -1) {
502                 location = 'mine';
503             }
504
505             // only keep mine if its not undefined or empty
506             if (this.resolveStrategy == 'mergeMine' && ['', 'null', 'undefined', '[]'].indexOf(String(mine)) < 0) {
507                 location = 'mine';
508             }
509
510             // special merge for tags
511             // TODO generalize me
512             if (resolveRecord.get('fieldName') == 'tags') {
513                 resolveRecord.set('finalValue', Tine.Tinebase.common.assertComparable([].concat(this.resolveStrategy != 'discard' ? mine : []).concat(this.resolveStrategy != 'keep' ? theirs : [])));
514             } else {
515                 resolveRecord.set('finalValue', location === 'mine' ? mine : theirs);
516             }
517             
518             Tine.log.debug('Tine.widgets.dialog.DuplicateResolveStore::applyStrategy() - resolved record field: ' + resolveRecord.get('fieldName'));
519             Tine.log.debug(resolveRecord);
520         }, this);
521         
522         this.commitChanges();
523     },
524     
525     checkEditGrant: function() {
526         var grant = ! this.recordClass.getMeta('containerProperty') ? true : this.duplicates[this.duplicateIdx].get('container_id') ? this.duplicates[this.duplicateIdx].get(this.recordClass.getMeta('containerProperty')).account_grants['editGrant'] : false;
527
528         // change strategy from merge to keep if user has no rights to merge
529         if (this.resolveStrategy.match(/^merge/) && ! grant) {
530             Tine.log.info('Tine.widgets.dialog.DuplicateResolveStore::checkEditGrant() - user has no editGrant, changing strategy to keep');
531             this.resolveStrategy = 'keep';
532             this.fireEvent('strategychange', this, this.resolveStrategy);
533         }
534     },
535
536     /**
537      * returns record with conflict resolved data
538      */
539     getResolvedRecord: function() {
540         var record = (this.resolveStrategy == 'keep' ? this.clientRecord : this.duplicates[this.duplicateIdx]).copy();
541
542         this.each(function(resolveRecord) {
543             var fieldName = resolveRecord.get('fieldName'),
544                 finalValue = resolveRecord.get('finalValue'),
545                 modified = resolveRecord.modified || {};
546
547             // also record changes
548             if (modified.hasOwnProperty('finalValue')) {
549                 Tine.log.debug('Tine.widgets.dialog.DuplicateResolveStore::getResolvedRecord ' + fieldName + ' changed from ' + modified.finalValue + ' to ' + finalValue);
550                 record.set(fieldName, Tine.Tinebase.common.assertComparable(modified.finalValue));
551             }
552
553             record.set(fieldName, Tine.Tinebase.common.assertComparable(finalValue));
554
555         }, this);
556
557         Tine.log.debug('Tine.widgets.dialog.DuplicateResolveStore::getResolvedRecord() resolved record:');
558         Tine.log.debug(record);
559         
560         return record;
561     },
562
563     /**
564      * create record from data
565      * 
566      * @param {Object} data
567      * @return {Record}
568      */
569     createRecord: function(data) {
570         return Ext.isFunction(data.beginEdit) ? data : this.recordProxy.recordReader({responseText: Ext.encode(data)});
571     }
572 });