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