Upgrade to ExtJS 4.0.7 - Released 10/19/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         if (column.hidden) {
446             me.onColumnHide(column);
447         }
448         if (me.isVisible() && me.context) {
449             me.renderColumnData(field, me.context.record);
450         }
451     },
452
453     loadRecord: function(record) {
454         var me = this,
455             form = me.getForm();
456         form.loadRecord(record);
457         if (form.isValid()) {
458             me.hideToolTip();
459         } else {
460             me.showToolTip();
461         }
462
463         // render display fields so they honor the column renderer/template
464         Ext.Array.forEach(me.query('>displayfield'), function(field) {
465             me.renderColumnData(field, record);
466         }, me);
467     },
468
469     renderColumnData: function(field, record) {
470         var me = this,
471             grid = me.editingPlugin.grid,
472             headerCt = grid.headerCt,
473             view = grid.view,
474             store = view.store,
475             column = me.columns.get(field.id),
476             value = record.get(column.dataIndex);
477
478         // honor our column's renderer (TemplateHeader sets renderer for us!)
479         if (column.renderer) {
480             var metaData = { tdCls: '', style: '' },
481                 rowIdx = store.indexOf(record),
482                 colIdx = headerCt.getHeaderIndex(column);
483
484             value = column.renderer.call(
485                 column.scope || headerCt.ownerCt,
486                 value,
487                 metaData,
488                 record,
489                 rowIdx,
490                 colIdx,
491                 store,
492                 view
493             );
494         }
495
496         field.setRawValue(value);
497         field.resetOriginalValue();
498     },
499
500     beforeEdit: function() {
501         var me = this;
502
503         if (me.isVisible() && !me.autoCancel && me.isDirty()) {
504             me.showToolTip();
505             return false;
506         }
507     },
508
509     /**
510      * Start editing the specified grid at the specified position.
511      * @param {Ext.data.Model} record The Store data record which backs the row to be edited.
512      * @param {Ext.data.Model} columnHeader The Column object defining the column to be edited.
513      */
514     startEdit: function(record, columnHeader) {
515         var me = this,
516             grid = me.editingPlugin.grid,
517             view = grid.getView(),
518             store = grid.store,
519             context = me.context = Ext.apply(me.editingPlugin.context, {
520                 view: grid.getView(),
521                 store: store
522             });
523
524         // make sure our row is selected before editing
525         context.grid.getSelectionModel().select(record);
526
527         // Reload the record data
528         me.loadRecord(record);
529
530         if (!me.isVisible()) {
531             me.show();
532             me.focusContextCell();
533         } else {
534             me.reposition({
535                 callback: this.focusContextCell
536             });
537         }
538     },
539
540     // Focus the cell on start edit based upon the current context
541     focusContextCell: function() {
542         var field = this.getEditor(this.context.colIdx);
543         if (field && field.focus) {
544             field.focus();
545         }
546     },
547
548     cancelEdit: function() {
549         var me = this,
550             form = me.getForm();
551
552         me.hide();
553         form.clearInvalid();
554         form.reset();
555     },
556
557     completeEdit: function() {
558         var me = this,
559             form = me.getForm();
560
561         if (!form.isValid()) {
562             return;
563         }
564
565         form.updateRecord(me.context.record);
566         me.hide();
567         return true;
568     },
569
570     onShow: function() {
571         var me = this;
572         me.callParent(arguments);
573         me.reposition();
574     },
575
576     onHide: function() {
577         var me = this;
578         me.callParent(arguments);
579         me.hideToolTip();
580         me.invalidateScroller();
581         if (me.context) {
582             me.context.view.focus();
583             me.context = null;
584         }
585     },
586
587     isDirty: function() {
588         var me = this,
589             form = me.getForm();
590         return form.isDirty();
591     },
592
593     getToolTip: function() {
594         var me = this,
595             tip;
596
597         if (!me.tooltip) {
598             tip = me.tooltip = Ext.createWidget('tooltip', {
599                 cls: Ext.baseCSSPrefix + 'grid-row-editor-errors',
600                 title: me.errorsText,
601                 autoHide: false,
602                 closable: true,
603                 closeAction: 'disable',
604                 anchor: 'left'
605             });
606         }
607         return me.tooltip;
608     },
609
610     hideToolTip: function() {
611         var me = this,
612             tip = me.getToolTip();
613         if (tip.rendered) {
614             tip.disable();
615         }
616         me.hiddenTip = false;
617     },
618
619     showToolTip: 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
626         tip.setTarget(row);
627         tip.showAt([-10000, -10000]);
628         tip.body.update(me.getErrors());
629         tip.mouseOffset = [viewEl.getWidth() - row.getWidth() + me.lastScrollLeft + 15, 0];
630         me.repositionTip();
631         tip.doLayout();
632         tip.enable();
633     },
634
635     repositionTip: function() {
636         var me = this,
637             tip = me.getToolTip(),
638             context = me.context,
639             row = Ext.get(context.row),
640             viewEl = context.grid.view.el,
641             viewHeight = viewEl.getHeight(),
642             viewTop = me.lastScrollTop,
643             viewBottom = viewTop + viewHeight,
644             rowHeight = row.getHeight(),
645             rowTop = row.dom.offsetTop,
646             rowBottom = rowTop + rowHeight;
647
648         if (rowBottom > viewTop && rowTop < viewBottom) {
649             tip.show();
650             me.hiddenTip = false;
651         } else {
652             tip.hide();
653             me.hiddenTip = true;
654         }
655     },
656
657     getErrors: function() {
658         var me = this,
659             dirtyText = !me.autoCancel && me.isDirty() ? me.dirtyText + '<br />' : '',
660             errors = [];
661
662         Ext.Array.forEach(me.query('>[isFormField]'), function(field) {
663             errors = errors.concat(
664                 Ext.Array.map(field.getErrors(), function(e) {
665                     return '<li>' + e + '</li>';
666                 })
667             );
668         }, me);
669
670         return dirtyText + '<ul>' + errors.join('') + '</ul>';
671     },
672
673     invalidateScroller: function() {
674         var me = this,
675             context = me.context,
676             scroller = context.grid.verticalScroller;
677
678         if (scroller) {
679             scroller.invalidate();
680         }
681     }
682 });