Upgrade to ExtJS 4.0.1 - Released 05/18/2011
[extjs.git] / src / grid / RowEditor.js
1 // Currently has the following issues:
2 // - Does not handle postEditValue
3 // - Fields without editors need to sync with their values in Store
4 // - starting to edit another record while already editing and dirty should probably prevent it
5 // - aggregating validation messages
6 // - tabIndex is not managed bc we leave elements in dom, and simply move via positioning
7 // - layout issues when changing sizes/width while hidden (layout bug)
8
9 /**
10  * @class Ext.grid.RowEditor
11  * @extends Ext.form.Panel
12  *
13  * Internal utility class used to provide row editing functionality. For developers, they should use
14  * the RowEditing plugin to use this functionality with a grid.
15  *
16  * @ignore
17  */
18 Ext.define('Ext.grid.RowEditor', {
19     extend: 'Ext.form.Panel',
20     requires: [
21         'Ext.tip.ToolTip',
22         'Ext.util.HashMap',
23         'Ext.util.KeyNav'
24     ],
25
26     saveBtnText  : 'Update',
27     cancelBtnText: 'Cancel',
28     errorsText: 'Errors',
29     dirtyText: 'You need to commit or cancel your changes',
30
31     lastScrollLeft: 0,
32     lastScrollTop: 0,
33
34     border: false,
35     
36     // Change the hideMode to offsets so that we get accurate measurements when
37     // the roweditor is hidden for laying out things like a TriggerField.
38     hideMode: 'offsets',
39
40     initComponent: function() {
41         var me = this,
42             form;
43
44         me.cls = Ext.baseCSSPrefix + 'grid-row-editor';
45
46         me.layout = {
47             type: 'hbox',
48             align: 'middle'
49         };
50
51         // Maintain field-to-column mapping
52         // It's easy to get a field from a column, but not vice versa
53         me.columns = Ext.create('Ext.util.HashMap');
54         me.columns.getKey = function(columnHeader) {
55             var f;
56             if (columnHeader.getEditor) {
57                 f = columnHeader.getEditor();
58                 if (f) {
59                     return f.id;
60                 }
61             }
62             return columnHeader.id;
63         };
64         me.mon(me.columns, {
65             add: me.onFieldAdd,
66             remove: me.onFieldRemove,
67             replace: me.onFieldReplace,
68             scope: me
69         });
70
71         me.callParent(arguments);
72
73         if (me.fields) {
74             me.setField(me.fields);
75             delete me.fields;
76         }
77
78         form = me.getForm();
79         form.trackResetOnLoad = true;
80     },
81
82     onFieldChange: function() {
83         var me = this,
84             form = me.getForm(),
85             valid = form.isValid();
86         if (me.errorSummary && me.isVisible()) {
87             me[valid ? 'hideToolTip' : 'showToolTip']();
88         }
89         if (me.floatingButtons) {
90             me.floatingButtons.child('#update').setDisabled(!valid);
91         }
92         me.isValid = valid;
93     },
94
95     afterRender: function() {
96         var me = this,
97             plugin = me.editingPlugin;
98
99         me.callParent(arguments);
100         me.mon(me.renderTo, 'scroll', me.onCtScroll, me, { buffer: 100 });
101
102         // Prevent from bubbling click events to the grid view
103         me.mon(me.el, {
104             click: Ext.emptyFn,
105             stopPropagation: true
106         });
107
108         me.el.swallowEvent([
109             'keypress',
110             'keydown'
111         ]);
112
113         me.keyNav = Ext.create('Ext.util.KeyNav', me.el, {
114             enter: plugin.completeEdit,
115             esc: plugin.onEscKey,
116             scope: plugin
117         });
118
119         me.mon(plugin.view, {
120             beforerefresh: me.onBeforeViewRefresh,
121             refresh: me.onViewRefresh,
122             scope: me
123         });
124     },
125
126     onBeforeViewRefresh: function(view) {
127         var me = this,
128             viewDom = view.el.dom;
129
130         if (me.el.dom.parentNode === viewDom) {
131             viewDom.removeChild(me.el.dom);
132         }
133     },
134
135     onViewRefresh: function(view) {
136         var me = this,
137             viewDom = view.el.dom,
138             context = me.context,
139             idx;
140
141         viewDom.appendChild(me.el.dom);
142
143         // Recover our row node after a view refresh
144         if (context && (idx = context.store.indexOf(context.record)) >= 0) {
145             context.row = view.getNode(idx);
146             me.reposition();
147             if (me.tooltip && me.tooltip.isVisible()) {
148                 me.tooltip.setTarget(context.row);
149             }
150         } else {
151             me.editingPlugin.cancelEdit();
152         }
153     },
154
155     onCtScroll: function(e, target) {
156         var me = this,
157             scrollTop  = target.scrollTop,
158             scrollLeft = target.scrollLeft;
159
160         if (scrollTop !== me.lastScrollTop) {
161             me.lastScrollTop = scrollTop;
162             if ((me.tooltip && me.tooltip.isVisible()) || me.hiddenTip) {
163                 me.repositionTip();
164             }
165         }
166         if (scrollLeft !== me.lastScrollLeft) {
167             me.lastScrollLeft = scrollLeft;
168             me.reposition();
169         }
170     },
171
172     onColumnAdd: function(column) {
173         this.setField(column);
174     },
175
176     onColumnRemove: function(column) {
177         this.columns.remove(column);
178     },
179
180     onColumnResize: function(column, width) {
181         column.getEditor().setWidth(width - 2);
182         if (this.isVisible()) {
183             this.reposition();
184         }
185     },
186
187     onColumnHide: function(column) {
188         column.getEditor().hide();
189         if (this.isVisible()) {
190             this.reposition();
191         }
192     },
193
194     onColumnShow: function(column) {
195         var field = column.getEditor();
196         field.setWidth(column.getWidth() - 2).show();
197         if (this.isVisible()) {
198             this.reposition();
199         }
200     },
201
202     onColumnMove: function(column, fromIdx, toIdx) {
203         var field = column.getEditor();
204         if (this.items.indexOf(field) != toIdx) {
205             this.move(fromIdx, toIdx);
206         }
207     },
208
209     onFieldAdd: function(map, fieldId, column) {
210         var me = this,
211             colIdx = me.editingPlugin.grid.headerCt.getHeaderIndex(column),
212             field = column.getEditor({ xtype: 'displayfield' });
213
214         me.insert(colIdx, field);
215     },
216
217     onFieldRemove: function(map, fieldId, column) {
218         var me = this,
219             field = column.getEditor(),
220             fieldEl = field.el;
221         me.remove(field, false);
222         if (fieldEl) {
223             fieldEl.remove();
224         }
225     },
226
227     onFieldReplace: function(map, fieldId, column, oldColumn) {
228         var me = this;
229         me.onFieldRemove(map, fieldId, oldColumn);
230     },
231
232     clearFields: function() {
233         var me = this,
234             map = me.columns;
235         map.each(function(fieldId) {
236             map.removeAtKey(fieldId);
237         });
238     },
239
240     getFloatingButtons: function() {
241         var me = this,
242             cssPrefix = Ext.baseCSSPrefix,
243             btnsCss = cssPrefix + 'grid-row-editor-buttons',
244             plugin = me.editingPlugin,
245             btns;
246
247         if (!me.floatingButtons) {
248             btns = me.floatingButtons = Ext.create('Ext.Container', {
249                 renderTpl: [
250                     '<div class="{baseCls}-ml"></div>',
251                     '<div class="{baseCls}-mr"></div>',
252                     '<div class="{baseCls}-bl"></div>',
253                     '<div class="{baseCls}-br"></div>',
254                     '<div class="{baseCls}-bc"></div>'
255                 ],
256
257                 renderTo: me.el,
258                 baseCls: btnsCss,
259                 layout: {
260                     type: 'hbox',
261                     align: 'middle'
262                 },
263                 defaults: {
264                     margins: '0 1 0 1'
265                 },
266                 items: [{
267                     itemId: 'update',
268                     flex: 1,
269                     xtype: 'button',
270                     handler: plugin.completeEdit,
271                     scope: plugin,
272                     text: me.saveBtnText,
273                     disabled: !me.isValid
274                 }, {
275                     flex: 1,
276                     xtype: 'button',
277                     handler: plugin.cancelEdit,
278                     scope: plugin,
279                     text: me.cancelBtnText
280                 }]
281             });
282
283             // Prevent from bubbling click events to the grid view
284             me.mon(btns.el, {
285                 // BrowserBug: Opera 11.01
286                 //   causes the view to scroll when a button is focused from mousedown
287                 mousedown: Ext.emptyFn,
288                 click: Ext.emptyFn,
289                 stopEvent: true
290             });
291         }
292         return me.floatingButtons;
293     },
294
295     reposition: function(animateConfig) {
296         var me = this,
297             context = me.context,
298             row = context && Ext.get(context.row),
299             btns = me.getFloatingButtons(),
300             btnEl = btns.el,
301             grid = me.editingPlugin.grid,
302             viewEl = grid.view.el,
303             scroller = grid.verticalScroller,
304
305             // always get data from ColumnModel as its what drives
306             // the GridView's sizing
307             mainBodyWidth = grid.headerCt.getFullWidth(),
308             scrollerWidth = grid.getWidth(),
309
310             // use the minimum as the columns may not fill up the entire grid
311             // width
312             width = Math.min(mainBodyWidth, scrollerWidth),
313             scrollLeft = grid.view.el.dom.scrollLeft,
314             btnWidth = btns.getWidth(),
315             left = (width - btnWidth) / 2 + scrollLeft,
316             y, rowH, newHeight,
317
318             invalidateScroller = function() {
319                 if (scroller) {
320                     scroller.invalidate();
321                     btnEl.scrollIntoView(viewEl, false);
322                 }
323                 if (animateConfig && animateConfig.callback) {
324                     animateConfig.callback.call(animateConfig.scope || me);
325                 }
326             };
327
328         // need to set both top/left
329         if (row && Ext.isElement(row.dom)) {
330             // Bring our row into view if necessary, so a row editor that's already
331             // visible and animated to the row will appear smooth
332             row.scrollIntoView(viewEl, false);
333
334             // Get the y position of the row relative to its top-most static parent.
335             // offsetTop will be relative to the table, and is incorrect
336             // when mixed with certain grid features (e.g., grouping).
337             y = row.getXY()[1] - 5;
338             rowH = row.getHeight();
339             newHeight = rowH + 10;
340
341             // IE doesn't set the height quite right.
342             // This isn't a border-box issue, it even happens
343             // in IE8 and IE7 quirks.
344             // TODO: Test in IE9!
345             if (Ext.isIE) {
346                 newHeight += 2;
347             }
348
349             // Set editor height to match the row height
350             if (me.getHeight() != newHeight) {
351                 me.setHeight(newHeight);
352                 me.el.setLeft(0);
353             }
354
355             if (animateConfig) {
356                 var animObj = {
357                     to: {
358                         y: y
359                     },
360                     duration: animateConfig.duration || 125,
361                     listeners: {
362                         afteranimate: function() {
363                             invalidateScroller();
364                             y = row.getXY()[1] - 5;
365                             me.el.setY(y);
366                         }
367                     }
368                 };
369                 me.animate(animObj);
370             } else {
371                 me.el.setY(y);
372                 invalidateScroller();
373             }
374         }
375         if (me.getWidth() != mainBodyWidth) {
376             me.setWidth(mainBodyWidth);
377         }
378         btnEl.setLeft(left);
379     },
380
381     getEditor: function(fieldInfo) {
382         var me = this;
383
384         if (Ext.isNumber(fieldInfo)) {
385             // Query only form fields. This just future-proofs us in case we add
386             // other components to RowEditor later on.  Don't want to mess with
387             // indices.
388             return me.query('>[isFormField]')[fieldInfo];
389         } else if (fieldInfo instanceof Ext.grid.column.Column) {
390             return fieldInfo.getEditor();
391         }
392     },
393
394     removeField: function(field) {
395         var me = this;
396
397         // Incase we pass a column instead, which is fine
398         field = me.getEditor(field);
399         me.mun(field, 'validitychange', me.onValidityChange, me);
400
401         // Remove field/column from our mapping, which will fire the event to
402         // remove the field from our container
403         me.columns.removeKey(field.id);
404     },
405
406     setField: function(column) {
407         var me = this,
408             field;
409
410         if (Ext.isArray(column)) {
411             Ext.Array.forEach(column, me.setField, me);
412             return;
413         }
414
415         // Get a default display field if necessary
416         field = column.getEditor(null, {
417             xtype: 'displayfield',
418             // Default display fields will not return values. This is done because
419             // the display field will pick up column renderers from the grid.
420             getModelData: function() {
421                 return null;
422             }
423         });
424         field.margins = '0 0 0 2';
425         field.setWidth(column.getDesiredWidth() - 2);
426         me.mon(field, 'change', me.onFieldChange, me);
427
428         // Maintain mapping of fields-to-columns
429         // This will fire events that maintain our container items
430         me.columns.add(field.id, column);
431         
432         if (me.isVisible() && me.context) {
433             me.renderColumnData(field, me.context.record);
434         }
435     },
436
437     loadRecord: function(record) {
438         var me = this,
439             form = me.getForm();
440         form.loadRecord(record);
441         if (form.isValid()) {
442             me.hideToolTip();
443         } else {
444             me.showToolTip();
445         }
446
447         // render display fields so they honor the column renderer/template
448         Ext.Array.forEach(me.query('>displayfield'), function(field) {
449             me.renderColumnData(field, record);
450         }, me);
451     },
452
453     renderColumnData: function(field, record) {
454         var me = this,
455             grid = me.editingPlugin.grid,
456             headerCt = grid.headerCt,
457             view = grid.view,
458             store = view.store,
459             column = me.columns.get(field.id),
460             value = record.get(column.dataIndex);
461
462         // honor our column's renderer (TemplateHeader sets renderer for us!)
463         if (column.renderer) {
464             var metaData = { tdCls: '', style: '' },
465                 rowIdx = store.indexOf(record),
466                 colIdx = headerCt.getHeaderIndex(column);
467
468             value = column.renderer.call(
469                 column.scope || headerCt.ownerCt,
470                 value,
471                 metaData,
472                 record,
473                 rowIdx,
474                 colIdx,
475                 store,
476                 view
477             );
478         }
479
480         field.setRawValue(value);
481         field.resetOriginalValue();
482     },
483
484     beforeEdit: function() {
485         var me = this;
486
487         if (me.isVisible() && !me.autoCancel && me.isDirty()) {
488             me.showToolTip();
489             return false;
490         }
491     },
492
493     /**
494      * Start editing the specified grid at the specified position.
495      * @param {Model} record The Store data record which backs the row to be edited.
496      * @param {Model} columnHeader The Column object defining the column to be edited.
497      */
498     startEdit: function(record, columnHeader) {
499         var me = this,
500             grid = me.editingPlugin.grid,
501             view = grid.getView(),
502             store = grid.store,
503             context = me.context = Ext.apply(me.editingPlugin.context, {
504                 view: grid.getView(),
505                 store: store
506             });
507
508         // make sure our row is selected before editing
509         context.grid.getSelectionModel().select(record);
510
511         // Reload the record data
512         me.loadRecord(record);
513
514         if (!me.isVisible()) {
515             me.show();
516             me.focusContextCell();
517         } else {
518             me.reposition({
519                 callback: this.focusContextCell
520             });
521         }
522     },
523
524     // Focus the cell on start edit based upon the current context
525     focusContextCell: function() {
526         var field = this.getEditor(this.context.colIdx);
527         if (field && field.focus) {
528             field.focus();
529         }
530     },
531
532     cancelEdit: function() {
533         var me = this,
534             form = me.getForm();
535
536         me.hide();
537         form.clearInvalid();
538         form.reset();
539     },
540
541     completeEdit: function() {
542         var me = this,
543             form = me.getForm();
544
545         if (!form.isValid()) {
546             return;
547         }
548
549         form.updateRecord(me.context.record);
550         me.hide();
551         return true;
552     },
553
554     onShow: function() {
555         var me = this;
556         me.callParent(arguments);
557         me.reposition();
558     },
559
560     onHide: function() {
561         var me = this;
562         me.callParent(arguments);
563         me.hideToolTip();
564         me.invalidateScroller();
565         if (me.context) {
566             me.context.view.focus();
567             me.context = null;
568         }
569     },
570
571     isDirty: function() {
572         var me = this,
573             form = me.getForm();
574         return form.isDirty();
575     },
576
577     getToolTip: function() {
578         var me = this,
579             tip;
580
581         if (!me.tooltip) {
582             tip = me.tooltip = Ext.createWidget('tooltip', {
583                 cls: Ext.baseCSSPrefix + 'grid-row-editor-errors',
584                 title: me.errorsText,
585                 autoHide: false,
586                 closable: true,
587                 closeAction: 'disable',
588                 anchor: 'left'
589             });
590         }
591         return me.tooltip;
592     },
593
594     hideToolTip: function() {
595         var me = this,
596             tip = me.getToolTip();
597         if (tip.rendered) {
598             tip.disable();
599         }
600         me.hiddenTip = false;
601     },
602
603     showToolTip: function() {
604         var me = this,
605             tip = me.getToolTip(),
606             context = me.context,
607             row = Ext.get(context.row),
608             viewEl = context.grid.view.el;
609
610         tip.setTarget(row);
611         tip.showAt([-10000, -10000]);
612         tip.body.update(me.getErrors());
613         tip.mouseOffset = [viewEl.getWidth() - row.getWidth() + me.lastScrollLeft + 15, 0];
614         me.repositionTip();
615         tip.doLayout();
616         tip.enable();
617     },
618
619     repositionTip: function() {
620         var me = this,
621             tip = me.getToolTip(),
622             context = me.context,
623             row = Ext.get(context.row),
624             viewEl = context.grid.view.el,
625             viewHeight = viewEl.getHeight(),
626             viewTop = me.lastScrollTop,
627             viewBottom = viewTop + viewHeight,
628             rowHeight = row.getHeight(),
629             rowTop = row.dom.offsetTop,
630             rowBottom = rowTop + rowHeight;
631
632         if (rowBottom > viewTop && rowTop < viewBottom) {
633             tip.show();
634             me.hiddenTip = false;
635         } else {
636             tip.hide();
637             me.hiddenTip = true;
638         }
639     },
640
641     getErrors: function() {
642         var me = this,
643             dirtyText = !me.autoCancel && me.isDirty() ? me.dirtyText + '<br />' : '',
644             errors = [];
645
646         Ext.Array.forEach(me.query('>[isFormField]'), function(field) {
647             errors = errors.concat(
648                 Ext.Array.map(field.getErrors(), function(e) {
649                     return '<li>' + e + '</li>';
650                 })
651             );
652         }, me);
653
654         return dirtyText + '<ul>' + errors.join('') + '</ul>';
655     },
656
657     invalidateScroller: function() {
658         var me = this,
659             context = me.context,
660             scroller = context.grid.verticalScroller;
661
662         if (scroller) {
663             scroller.invalidate();
664         }
665     }
666 });