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