// Currently has the following issues:
// - Does not handle postEditValue
// - Fields without editors need to sync with their values in Store
// - starting to edit another record while already editing and dirty should probably prevent it
// - aggregating validation messages
// - tabIndex is not managed bc we leave elements in dom, and simply move via positioning
// - layout issues when changing sizes/width while hidden (layout bug)
/**
* Internal utility class used to provide row editing functionality. For developers, they should use
* the RowEditing plugin to use this functionality with a grid.
*
* @private
*/
Ext.define('Ext.grid.RowEditor', {
extend: 'Ext.form.Panel',
alias: 'widget.roweditor',
requires: [
'Ext.tip.ToolTip',
'Ext.util.HashMap',
'Ext.util.KeyNav',
'Ext.grid.RowEditorButtons'
],
//<locale>
saveBtnText : 'Update',
//</locale>
//<locale>
cancelBtnText: 'Cancel',
//</locale>
//<locale>
errorsText: 'Errors',
//</locale>
//<locale>
dirtyText: 'You need to commit or cancel your changes',
//</locale>
lastScrollLeft: 0,
lastScrollTop: 0,
border: false,
buttonUI: 'default',
// Change the hideMode to offsets so that we get accurate measurements when
// the roweditor is hidden for laying out things like a TriggerField.
hideMode: 'offsets',
initComponent: function() {
var me = this,
form;
me.cls = Ext.baseCSSPrefix + 'grid-editor ' + Ext.baseCSSPrefix + 'grid-row-editor';
me.layout = {
type: 'hbox',
align: 'middle'
};
// Maintain field-to-column mapping
// It's easy to get a field from a column, but not vice versa
me.columns = new Ext.util.HashMap();
me.columns.getKey = function(columnHeader) {
var f;
if (columnHeader.getEditor) {
f = columnHeader.getEditor();
if (f) {
return f.id;
}
}
return columnHeader.id;
};
me.mon(me.columns, {
add: me.doColumnAdd,
remove: me.doColumnRemove,
replace: me.onColumnReplace,
scope: me
});
me.callParent(arguments);
if (me.fields) {
me.setField(me.fields, true);
delete me.fields;
}
me.mon(me.hierarchyEventSource, {
scope: me,
show: me.repositionIfVisible
});
// Ensure that the editor width always matches the total header width
me.mon(me.view.headerCt, 'afterlayout', me.correctWidth, me);
form = me.getForm();
form.trackResetOnLoad = true;
},
onFieldRender: function(field){
var me = this,
margins = me.getEditorMargins(field),
column = me.columns.get(field.id),
fn;
field.setMargin('0 ' + margins.right + ' 0 ' + margins.left, true);
if (column.isVisible()) {
me.setFieldWidth(column, field);
} else if (!column.rendered) {
// column is pending a layout, so we can't set the width until it does
fn = Ext.Function.bind(me.setFieldWidth, me, [column, field]);
me.mon(me.view.headerCt, 'afterlayout', fn, me, {
single: true
})
}
},
setFieldWidth: function(column, field) {
var margins = this.getEditorMargins(field);
field.setWidth(column.getWidth() - margins.width);
},
setupMargin: function(field) {
var me = this,
cellPadding = me.cellPadding,
view = me.view,
fieldPadLeft = 0,
fieldPadRight = 0,
offset = 1,
inputEl, margins,
cell;
// Get cell padding styles, only need to do this for the first time for this editor
if (!cellPadding) {
cell = view.el.down(view.cellSelector + ' ' + view.innerSelector);
if (cell) {
cellPadding = {
left: cell.getPadding('l'),
right: cell.getPadding('r'),
top: cell.getPadding('t'),
bottom: cell.getPadding('b')
};
} else {
// Trying to edit with no rows, need to fall back to 0/0.
cellPadding = {
left: 0,
right: 0
};
}
me.cellPadding = cellPadding;
}
// Only need to setup margins on a per field basis
inputEl = field.inputEl;
if (inputEl) {
fieldPadLeft = inputEl.getPadding('l');
fieldPadRight = inputEl.getPadding('r');
}
if (field.isXType('textfield')) {
offset = 1;
}
// Ensure we get at 1px of margin on each side so they arent flush against the edge
margins = {
left: Math.max(1, cellPadding.left - (fieldPadLeft + offset)),
right: Math.max(1, cellPadding.right - (fieldPadRight + offset)),
top: cellPadding.top,
bottom: cellPadding.bottom
};
margins.width = margins.left + margins.right;
field.editorMargin = margins;
return margins;
},
getEditorMargins: function(field) {
var margins = field.editorMargin;
// Cell inner padding is fixed.
if (!margins) {
margins = this.setupMargin(field);
}
return margins;
},
onFieldChange: function() {
var me = this,
form = me.getForm(),
valid = form.isValid();
if (me.errorSummary && me.isVisible()) {
me[valid ? 'hideToolTip' : 'showToolTip']();
}
me.updateButton(valid);
me.isValid = valid;
},
updateButton: function(valid){
var buttons = this.floatingButtons;
if (buttons) {
buttons.child('#update').setDisabled(!valid);
} else {
// set flag so we can disabled when created if needed
this.updateButtonDisabled = !valid;
}
},
afterRender: function() {
var me = this,
plugin = me.editingPlugin,
grid = plugin.grid,
field, margins;
me.callParent(arguments);
me.mon(me.container, 'scroll', me.onCtScroll, me, { buffer: 10 });
if (grid.lockable) {
grid.normalGrid.view.mon(grid.normalGrid.view.el, 'scroll', me.onNormalViewScroll, me, { buffer: 10 });
}
// Prevent from bubbling click events to the grid view
me.mon(me.el, {
click: Ext.emptyFn,
stopPropagation: true
});
me.el.swallowEvent([
'keypress',
'keydown'
]);
me.keyNav = new Ext.util.KeyNav(me.el, {
enter: plugin.completeEdit,
esc: plugin.onEscKey,
scope: plugin
});
me.mon(plugin.view, {
beforerefresh: me.onBeforeViewRefresh,
refresh: me.onViewRefresh,
itemremove: me.onViewItemRemove,
scope: me
});
// Prevent trying to reposition while we set everything up
me.preventReposition = true;
me.columns.each(function(fieldId, column) {
field = column.getEditor();
margins = me.getEditorMargins(field);
column.getEditor().setMargin('0 ' + margins.right + ' 0 ' + margins.left, true);
if (column.isVisible()) {
me.onColumnShow(column);
}
}, me);
delete me.preventReposition;
},
onBeforeViewRefresh: function(view) {
var me = this,
viewDom = view.el.dom;
if (me.el.dom.parentNode === viewDom) {
viewDom.removeChild(me.el.dom);
}
},
onViewRefresh: function(view) {
var me = this,
context = me.context,
idx;
me.container.dom.appendChild(me.el.dom);
// Recover our row node after a view refresh
if (context && (idx = context.store.indexOf(context.record)) >= 0) {
context.row = view.getNode(idx);
me.reposition();
if (me.tooltip && me.tooltip.isVisible()) {
me.tooltip.setTarget(context.row);
}
} else {
me.editingPlugin.cancelEdit();
}
},
onViewItemRemove: function(record, index) {
var context = this.context;
if (context && record === context.record) {
// if the record being edited was removed, cancel editing
this.editingPlugin.cancelEdit();
}
},
onCtScroll: function(e, target) {
var me = this,
scrollTop = target.scrollTop,
scrollLeft = Ext.fly(target).getScrollLeft();
if (scrollTop !== me.lastScrollTop) {
me.lastScrollTop = scrollTop;
if ((me.tooltip && me.tooltip.isVisible()) || me.hiddenTip) {
me.repositionTip();
}
}
if (scrollLeft !== me.lastScrollLeft) {
me.lastScrollLeft = scrollLeft;
me.reposition();
}
},
onNormalViewScroll: function(e, target) {
if (this.ignoreScroll) {
this.ignoreScroll = false;
return;
}
var me = this,
scrollTop = target.scrollTop;
if (scrollTop !== me.lastScrollTop) {
me.lastScrollTop = scrollTop;
if ((me.tooltip && me.tooltip.isVisible()) || me.hiddenTip) {
me.repositionTip();
}
}
this.reposition(null, true);
},
onColumnResize: function(column, width) {
var field;
if (!column.isGroupHeader && this.rendered) {
field = column.getEditor();
field.setWidth(width - this.getEditorMargins(field).width);
this.repositionIfVisible();
}
},
onColumnHide: function(column) {
if (!column.isGroupHeader) {
column.getEditor().hide();
this.repositionIfVisible();
}
},
onColumnShow: function(column) {
var me = this,
field;
if (!column.isGroupHeader) {
field = column.getEditor();
field.show();
if (me.rendered) {
field.setWidth(column.getWidth() - me.getEditorMargins(field).width);
if (!me.preventReposition) {
this.repositionIfVisible();
}
}
}
},
onColumnMove: function(column, fromIdx, toIdx) {
var grid = this.editingPlugin.grid,
lockedColCount;
// If moving within the normal (rightmost) grid, adjust the to/from positions
// so that they are correct in our unified field collection
if (grid.lockable && grid.normalGrid.headerCt.contains(column, true)) {
lockedColCount = grid.lockedGrid.view.getGridColumns().length;
fromIdx += lockedColCount;
toIdx += lockedColCount;
}
if (!column.isGroupHeader) {
var field = column.getEditor();
if (this.items.indexOf(field) != toIdx) {
this.move(fromIdx, toIdx);
}
}
},
onColumnAdd: function(column) {
this.doColumnAdd(this.columns, column.getEditor().id, column);
if (!column.isGroupHeader) {
this.setField(column);
}
},
doColumnAdd: function(map, fieldId, column){
var me = this,
colIdx,
field;
if (!column.isGroupHeader) {
colIdx = me.editingPlugin.grid.headerCt.getHeaderIndex(column);
field = column.getEditor({ xtype: 'displayfield' });
me.insert(colIdx, field);
}
},
onColumnRemove: function(column) {
this.doColumnRemove(this.columns, column.getEditor().id, column);
this.columns.remove(column);
},
doColumnRemove: function(map, fieldId, column){
var me = this,
field;
if (!column.isGroupHeader) {
field = column.getEditor();
me.remove(field, false);
}
},
onColumnReplace: function(map, fieldId, column, oldColumn) {
this.onColumnRemove(map, fieldId, oldColumn);
},
clearFields: function() {
var map = this.columns,
key;
for (key in map) {
if (map.hasOwnProperty(key)) {
map.removeAtKey(key);
}
}
},
getFloatingButtons: function() {
var me = this;
if (!me.floatingButtons) {
me.floatingButtons = new Ext.grid.RowEditorButtons({
rowEditor: me,
renderTo: me.el
});
}
return me.floatingButtons;
},
repositionIfVisible: function(c){
var me = this,
view = me.view;
// If we're showing ourselves, jump out
// If the component we're showing doesn't contain the view
if (c && (c == me || !c.el.isAncestor(view.el))) {
return;
}
if (me.isVisible() && view.isVisible(true)) {
me.reposition();
}
},
getRefOwner: function() {
return this.editingPlugin.grid;
},
reposition: function(animateConfig, doNotScroll) {
var me = this,
context = me.context,
row = context && Ext.get(context.row),
btns = me.getFloatingButtons(),
btnEl = btns.el,
grid = me.editingPlugin.grid,
viewEl = grid.lockable ? grid.normalGrid.view.el : grid.view.el,
// always get data from ColumnModel as its what drives
// the GridView's sizing
mainBodyWidth = grid.headerCt.getFullWidth(),
scrollerWidth = grid.getWidth(),
// use the minimum as the columns may not fill up the entire grid
// width
width = Math.min(mainBodyWidth, scrollerWidth),
scrollLeft = Ext.fly(grid.view.el.dom).getScrollLeft(),
btnWidth = btns.getWidth(),
left = (width - btnWidth) / 2 + scrollLeft,
localX = me.getLocalX(),
scrollDistance,
invalidateScroller = function() {
if (!doNotScroll) {
// If the buttons are out of view...
if ((scrollDistance = (btnEl.getRegion().bottom - grid.el.getRegion().bottom)) > 0) {
// Make some extra scroll space to enable them to scroll into view
if (grid.lockable) {
grid.normalGrid.view.body.dom.style.marginBottom =
grid.lockedGrid.view.body.dom.style.marginBottom = btnEl.getHeight() + 'px';
}
// Scroll the normal view ensuring we don't recurse
me.ignoreScroll = true;
viewEl.dom.scrollTop += scrollDistance;
// If grid is lockable, the editor will not have moved because it's rendered to the top grid
if (grid.lockable) {
me.setLocalY(me.getLocalY() - scrollDistance);
}
}
}
if (animateConfig && animateConfig.callback) {
animateConfig.callback.call(animateConfig.scope || me);
}
},
animObj;
// If on a Lockable, align to the normal side
if (grid.lockable) {
// We may not need the extra scrollable space - that's decided in the invalidateScroller callback
grid.normalGrid.view.body.dom.style.marginBottom =
grid.lockedGrid.view.body.dom.style.marginBottom = '';
localX += grid.normalGrid.view.el.dom.scrollLeft * (me.rtl ? 1 : -1);
}
// need to set both top/left
if (row && Ext.isElement(row.dom)) {
// Bring our row into view if necessary, so a row editor that's already
// visible and animated to the row will appear smooth
if (!doNotScroll) {
row.scrollIntoView(viewEl, false);
}
// Get the y position of the row relative to its top-most static parent.
// offsetTop will be relative to the table, and is incorrect
// when mixed with certain grid features (e.g., grouping).
me.setLocalX(localX);
if (animateConfig) {
animObj = {
to: {
y: row.getXY()[1] - me.body.getBorderPadding().beforeY
},
duration: animateConfig.duration || 125,
listeners: {
afteranimate: function() {
me.setButtonPosition(btnEl, left);
invalidateScroller();
}
}
};
me.animate(animObj);
} else {
me.setLocalY((grid.lockable ? row.getOffsetsTo(grid.body)[1] : row.dom.offsetTop) - me.body.getBorderPadding().beforeY);
me.setButtonPosition(btnEl, left);
invalidateScroller();
}
}
me.correctWidth();
},
// Private called when the owning grid header container lays out
correctWidth: function() {
var me = this,
mainBodyWidth;
// Do nothing if not rendered, or hidden, or if a refresh has kicked us out of the View's DOM
if (me.rendered && me.isVisible() && me.el.dom.parentNode) {
mainBodyWidth = me.editingPlugin.grid.headerCt.getFullWidth();
if (me.getWidth() != mainBodyWidth) {
me.setWidth(mainBodyWidth);
}
}
},
getLocalX: function() {
return 0;
},
setButtonPosition: function(btnEl, left){
btnEl.setLocalXY(left, this.el.dom.offsetHeight - 1);
},
getEditor: function(fieldInfo) {
var me = this;
if (Ext.isNumber(fieldInfo)) {
// Query only form fields. This just future-proofs us in case we add
// other components to RowEditor later on. Don't want to mess with
// indices.
return me.query('>[isFormField]')[fieldInfo];
} else if (fieldInfo.isHeader && !fieldInfo.isGroupHeader) {
return fieldInfo.getEditor();
}
},
removeField: function(field) {
var me = this;
// Incase we pass a column instead, which is fine
field = me.getEditor(field);
me.mun(field, 'validitychange', me.onValidityChange, me);
// Remove field/column from our mapping, which will fire the event to
// remove the field from our container
me.columns.removeAtKey(field.id);
Ext.destroy(field);
},
setField: function(column, initial) {
var me = this,
i,
length, field;
if (Ext.isArray(column)) {
length = column.length;
for (i = 0; i < length; i++) {
me.setField(column[i], initial);
}
return;
}
// Get a default display field if necessary
field = column.getEditor(null, {
xtype: 'displayfield',
// Override Field's implementation so that the default display fields will not return values. This is done because
// the display field will pick up column renderers from the grid.
getModelData: function() {
return null;
}
});
me.mon(field, 'change', me.onFieldChange, me);
if (me.rendered) {
// Only setup the margins if we're already rendered, otherwise this
// will be handled automatically when the editor renders
me.mon(field, 'afterrender', me.onFieldRender, me, {
single: true
});
}
if (me.isVisible() && me.context) {
if (field.is('displayfield')) {
me.renderColumnData(field, me.context.record, column);
} else {
field.suspendEvents();
field.setValue(me.context.record.get(column.dataIndex));
field.resumeEvents();
}
}
// Maintain mapping of fields-to-columns
// This will fire events that maintain our container items
me.columns.add(field.id, column);
if (column.hidden) {
me.onColumnHide(column);
} else if (column.rendered && !initial) {
// Setting after initial render
me.onColumnShow(column);
}
},
loadRecord: function(record) {
var me = this,
form = me.getForm(),
fields = form.getFields(),
items = fields.items,
length = items.length,
i, displayFields,
isValid;
// temporarily suspend events on form fields before loading record to prevent the fields' change events from firing
for (i = 0; i < length; i++) {
items[i].suspendEvents();
}
form.loadRecord(record);
for (i = 0; i < length; i++) {
items[i].resumeEvents();
}
isValid = form.isValid();
if (me.errorSummary) {
if (isValid) {
me.hideToolTip();
} else {
me.showToolTip();
}
}
me.updateButton(isValid);
// render display fields so they honor the column renderer/template
displayFields = me.query('>displayfield');
length = displayFields.length;
for (i = 0; i < length; i++) {
me.renderColumnData(displayFields[i], record);
}
},
renderColumnData: function(field, record, activeColumn) {
var me = this,
grid = me.editingPlugin.grid,
headerCt = grid.headerCt,
view = grid.view,
store = view.store,
column = activeColumn || me.columns.get(field.id),
value = record.get(column.dataIndex),
renderer = column.editRenderer || column.renderer,
metaData,
rowIdx,
colIdx;
// honor our column's renderer (TemplateHeader sets renderer for us!)
if (renderer) {
metaData = { tdCls: '', style: '' };
rowIdx = store.indexOf(record);
colIdx = headerCt.getHeaderIndex(column);
value = renderer.call(
column.scope || headerCt.ownerCt,
value,
metaData,
record,
rowIdx,
colIdx,
store,
view
);
}
field.setRawValue(value);
field.resetOriginalValue();
},
beforeEdit: function() {
var me = this;
if (me.isVisible() && me.errorSummary && !me.autoCancel && me.isDirty()) {
me.showToolTip();
return false;
}
},
/**
* Start editing the specified grid at the specified position.
* @param {Ext.data.Model} record The Store data record which backs the row to be edited.
* @param {Ext.data.Model} columnHeader The Column object defining the column to be edited.
*/
startEdit: function(record, columnHeader) {
var me = this,
grid = me.editingPlugin.grid,
store = grid.store,
view = grid.getView(),
context = me.context = Ext.apply(me.editingPlugin.context, {
view: view,
store: store
});
if (!me.rendered) {
me.render(view.el);
}
// make sure our row is selected before editing
context.grid.getSelectionModel().select(record);
// Reload the record data
me.loadRecord(record);
if (!me.isVisible()) {
me.show();
}
me.reposition({
callback: this.focusContextCell
});
},
// Focus the cell on start edit based upon the current context
focusContextCell: function() {
var field = this.getEditor(this.context.colIdx);
if (field && field.focus) {
field.focus();
}
},
cancelEdit: function() {
var me = this,
form = me.getForm(),
fields = form.getFields(),
items = fields.items,
length = items.length,
i;
me.hide();
form.clearInvalid();
// temporarily suspend events on form fields before reseting the form to prevent the fields' change events from firing
for (i = 0; i < length; i++) {
items[i].suspendEvents();
}
form.reset();
for (i = 0; i < length; i++) {
items[i].resumeEvents();
}
},
completeEdit: function() {
var me = this,
form = me.getForm();
if (!form.isValid()) {
return;
}
form.updateRecord(me.context.record);
me.hide();
return true;
},
onShow: function() {
this.callParent(arguments);
this.reposition();
},
onHide: function() {
var me = this;
me.callParent(arguments);
if (me.tooltip) {
me.hideToolTip();
}
if (me.context) {
me.context.view.focus();
me.context = null;
}
},
isDirty: function() {
var me = this,
form = me.getForm();
return form.isDirty();
},
getToolTip: function() {
return this.tooltip || (this.tooltip = new Ext.tip.ToolTip({
cls: Ext.baseCSSPrefix + 'grid-row-editor-errors',
title: this.errorsText,
autoHide: false,
closable: true,
closeAction: 'disable',
anchor: 'left',
anchorToTarget: false
}));
},
hideToolTip: function() {
var me = this,
tip = me.getToolTip();
if (tip.rendered) {
tip.disable();
}
me.hiddenTip = false;
},
showToolTip: function() {
var me = this,
tip = me.getToolTip();
tip.showAt([0, 0]);
tip.update(me.getErrors());
me.repositionTip();
tip.enable();
},
repositionTip: function() {
var me = this,
tip = me.getToolTip(),
context = me.context,
row = Ext.get(context.row),
viewEl = context.grid.view.el,
viewHeight = viewEl.getHeight(),
viewTop = me.lastScrollTop,
viewBottom = viewTop + viewHeight,
rowHeight = row.getHeight(),
rowTop = row.dom.offsetTop,
rowBottom = rowTop + rowHeight;
if (rowBottom > viewTop && rowTop < viewBottom) {
tip.showAt(tip.getAlignToXY(viewEl, 'tl-tr', [15, row.getOffsetsTo(viewEl)[1]]));
me.hiddenTip = false;
} else {
tip.hide();
me.hiddenTip = true;
}
},
getErrors: function() {
var me = this,
dirtyText = !me.autoCancel && me.isDirty() ? me.dirtyText + '<br />' : '',
errors = [],
fields = me.query('>[isFormField]'),
length = fields.length,
i;
for (i = 0; i < length; i++) {
errors = errors.concat(
Ext.Array.map(fields[i].getErrors(), me.createListItem)
);
}
return dirtyText + '<ul class="' + Ext.plainListCls + '">' + errors.join('') + '</ul>';
},
createListItem: function(e) {
return '<li>' + e + '</li>';
},
beforeDestroy: function(){
Ext.destroy(this.floatingButtons, this.tooltip);
this.callParent();
}
});