define([ 'dojo/_base/declare', 'dojo/_base/lang', 'dojo/Deferred', 'dojo/dom-construct', 'dojo/dom-class', 'dojo/on', 'dojo/has', 'dojo/query', './Grid', 'dojo/_base/sniff' ], function (declare, lang, Deferred, domConstruct, domClass, on, has, query, Grid) { return declare(null, { constructor: function () { this._editorInstances = {}; // Tracks shared editor dismissal listeners, and editor click/change listeners for old IE this._editorColumnListeners = []; // Tracks always-on editor listeners for old IE, or listeners for triggering shared editors this._editorCellListeners = {}; this._editorsPendingStartup = []; }, postCreate: function () { var self = this; this.inherited(arguments); this.on('.dgrid-input:focusin', function () { self._focusedEditorCell = self.cell(this); }); this._editorFocusoutHandle = on.pausable(this.domNode, '.dgrid-input:focusout', function () { self._focusedEditorCell = null; }); this._listeners.push(this._editorFocusoutHandle); }, insertRow: function () { this._editorRowListeners = {}; var rowElement = this.inherited(arguments); var row = this.row(rowElement); var rowListeners = this._editorCellListeners[rowElement.id] = this._editorCellListeners[rowElement.id] || {}; for (var key in this._editorRowListeners) { rowListeners[key] = this._editorRowListeners[key]; } // Null this out so that _createEditor can tell whether the editor being created is // an individual cell editor at insertion time, vs. a cell being refreshed this._editorRowListeners = null; var previouslyFocusedCell = this._previouslyFocusedEditorCell; if (previouslyFocusedCell && previouslyFocusedCell.row.id === row.id) { this.edit(this.cell(row, previouslyFocusedCell.column.id)); } return rowElement; }, refresh: function () { for (var id in this._editorInstances) { var editorInstanceDomNode = this._editorInstances[id].domNode; if (editorInstanceDomNode && editorInstanceDomNode.parentNode) { // Remove any editor widgets from the DOM before List destroys it, to avoid issues in IE (#1100) editorInstanceDomNode.parentNode.removeChild(editorInstanceDomNode); } } return this.inherited(arguments); }, removeRow: function (rowElement) { var self = this; var focusedCell = this._focusedEditorCell; if (focusedCell && focusedCell.row.id === this.row(rowElement).id) { this._previouslyFocusedEditorCell = focusedCell; // Pause the focusout handler until after this row has had // time to re-render, if this removal is part of an update. // A setTimeout is used here instead of resuming in insertRow, // since if a row were actually removed (not updated) while // editing, the handler would not be properly hooked up again // for future occurrences. this._editorFocusoutHandle.pause(); setTimeout(function () { self._editorFocusoutHandle.resume(); self._previouslyFocusedEditorCell = null; }, 0); } if (this._editorCellListeners[rowElement.id]) { for (var columnId in this._editorCellListeners[rowElement.id]) { this._editorCellListeners[rowElement.id][columnId].remove(); } delete this._editorCellListeners[rowElement.id]; } for (var i = this._alwaysOnWidgetColumns.length; i--;) { // Destroy always-on editor widgets during the row removal operation, // but don't trip over loading nodes from incomplete requests var cellElement = this.cell(rowElement, this._alwaysOnWidgetColumns[i].id).element, widget = cellElement && (cellElement.contents || cellElement).widget; if (widget) { this._editorFocusoutHandle.pause(); widget.destroyRecursive(); } } return this.inherited(arguments); }, renderArray: function () { var rows = this.inherited(arguments); if (rows.length) { // Finish processing any pending editors that are now displayed this._startupPendingEditors(); } else { this._editorsPendingStartup = []; } return rows; }, _onNotification: function () { this.inherited(arguments); this._startupPendingEditors(); }, _destroyColumns: function () { this._editorStructureCleanup(); this.inherited(arguments); }, _editorStructureCleanup: function () { var editorInstances = this._editorInstances; var listeners = this._editorColumnListeners; if (this._editTimer) { clearTimeout(this._editTimer); } // Do any clean up of previous column structure. for (var columnId in editorInstances) { var editor = editorInstances[columnId]; if (editor.domNode) { // The editor is a widget editor.destroyRecursive(); } } this._editorInstances = {}; for (var i = listeners.length; i--;) { listeners[i].remove(); } for (var rowId in this._editorCellListeners) { for (columnId in this._editorCellListeners[rowId]) { this._editorCellListeners[rowId][columnId].remove(); } } for (i = 0; i < this._editorColumnListeners.length; i++) { this._editorColumnListeners[i].remove(); } this._editorCellListeners = {}; this._editorColumnListeners = []; this._editorsPendingStartup = []; }, _configColumns: function () { var columnArray = this.inherited(arguments); this._alwaysOnWidgetColumns = []; for (var i = 0, l = columnArray.length; i < l; i++) { if (columnArray[i].editor) { this._configureEditorColumn(columnArray[i]); } } return columnArray; }, _configureEditorColumn: function (column) { // summary: // Adds editing capability to a column's cells. var editor = column.editor; var self = this; var originalRenderCell = column.renderCell || this._defaultRenderCell; var editOn = column.editOn; var isWidget = typeof editor !== 'string'; if (editOn) { // Create one shared widget/input to be swapped into the active cell. this._editorInstances[column.id] = this._createSharedEditor(column, originalRenderCell); } else if (isWidget) { // Append to array iterated in removeRow this._alwaysOnWidgetColumns.push(column); } column.renderCell = editOn ? function (object, value, cell, options) { // TODO: Consider using event delegation // (Would require using dgrid's focus events for activating on focus, // which we already advocate in docs for optimal use) if (!options || !options.alreadyHooked) { var listener = on(cell, editOn, function () { self._activeOptions = options; self.edit(this); }); if (self._editorRowListeners) { self._editorRowListeners[column.id] = listener; } else { // We're in refreshCell since _editorRowListeners doesn't exist, // so the row should exist var row = self.row(object); self._editorCellListeners[row.element.id][column.id] = listener; } } // initially render content in non-edit mode return originalRenderCell.call(column, object, value, cell, options); } : function (object, value, cell, options) { // always-on: create editor immediately upon rendering each cell if (!column.canEdit || column.canEdit(object, value)) { // _createEditor also needs the object for when this is invoked via refreshCell, to get the row var cmp = self._createEditor(column, object); self._showEditor(cmp, column, cell, value); // Maintain reference for later use. cell[isWidget ? 'widget' : 'input'] = cmp; } else { return originalRenderCell.call(column, object, value, cell, options); } }; }, edit: function (cell) { // summary: // Shows/focuses the editor for a given grid cell. // cell: Object // Cell (or something resolvable by grid.cell) to activate editor on. // returns: // If the cell is editable, returns a promise resolving to the editor // input/widget when the cell editor is focused. // If the cell is not editable, returns null. var self = this; var column; var cellElement; var dirty; var field; var value; var cmp; var dfd; function showEditor(dfd) { self._activeCell = cellElement; self._showEditor(cmp, column, cellElement, value); // focus / blur-handler-resume logic is surrounded in a setTimeout // to play nice with Keyboard's dgrid-cellfocusin as an editOn event self._editTimer = setTimeout(function () { // focus the newly-placed control (supported by form widgets and HTML inputs) if (cmp.focus) { cmp.focus(); } // resume blur handler once editor is focused if (column._editorBlurHandle) { column._editorBlurHandle.resume(); } self._editTimer = null; dfd.resolve(cmp); }, 0); } if (!cell.column) { cell = this.cell(cell); } if (!cell || !cell.element) { return null; } column = cell.column; field = column.field; cellElement = cell.element.contents || cell.element; if ((cmp = this._editorInstances[column.id])) { // Shared editor (editOn used) if (this._activeCell !== cellElement) { // Get the cell value var row = cell.row; dirty = this.dirty && this.dirty[row.id]; value = (dirty && field in dirty) ? dirty[field] : column.get ? column.get(row.data) : row.data[field]; // Check to see if the cell can be edited if (!column.canEdit || column.canEdit(cell.row.data, value)) { dfd = new Deferred(); // In some browsers, moving a DOM node causes a blur event to fire which in this case, // is a bad time for the blur handler to run. Blur the input node first. var node = cmp.domNode || cmp; if (node.offsetWidth) { // The editor is visible. Blur it. node.blur(); // In IE, the blur does not complete immediately. // Push showing of the editor to the next turn. // (dfd will be resolved within showEditor) setTimeout(function () { showEditor(dfd); }, 0); } else { showEditor(dfd); } return dfd.promise; } } } else if (column.editor) { // editor but not shared; always-on cmp = cellElement.widget || cellElement.input; if (cmp) { dfd = new Deferred(); if (cmp.focus) { cmp.focus(); } dfd.resolve(cmp); return dfd.promise; } } return null; }, refreshCell: function (cell) { var column = cell.column; var value = column.get ? column.get(cell.row.data) : cell.row.data[column.field]; var editor; if (column.editor) { if (cell.column.editOn && this._activeCell === cell.element) { editor = this._editorInstances[cell.column.id]; } else if (!cell.column.editOn) { editor = cell.element.widget || cell.element.input; } } if (editor) { if (editor.domNode) { editor.set('value', value); } else { this._updateInputValue(editor, value); } return (new Deferred()).resolve(); } return this.inherited(arguments); }, _showEditor: function (cmp, column, cellElement, value) { // Places a shared editor into the newly-active cell in the column. // Also called when rendering an editor in an "always-on" editor column. var isWidget = cmp.domNode; // for regular inputs, we can update the value before even showing it if (!isWidget) { this._updateInputValue(cmp, value); } cellElement.innerHTML = ''; domClass.add(cellElement, 'dgrid-cell-editing'); // If a shared editor is a validation widget, reset it to clear validation state // (The value will be preserved since it is explicitly set in _startupEditor) if (isWidget && column.editOn && cmp.validate && cmp.reset) { cmp.reset(); } cellElement.appendChild(cmp.domNode || cmp); if (isWidget && !column.editOn) { // Queue arguments to be run once editor is in DOM this._editorsPendingStartup.push([cmp, column, cellElement, value]); } else { this._startupEditor(cmp, column, cellElement, value); } }, _startupEditor: function (cmp, column, cellElement, value) { // summary: // Handles editor widget startup logic and updates the editor's value. if (cmp.domNode) { // For widgets, ensure startup is called before setting value, to maximize compatibility // with flaky widgets like dijit/form/Select. if (!cmp._started) { cmp.startup(); } // Set value, but ensure it isn't processed as a user-generated change. // (Clear flag on a timeout to wait for delayed onChange to fire first) cmp._dgridIgnoreChange = true; cmp.set('value', value); setTimeout(function () { cmp._dgridIgnoreChange = false; }, 0); } // track previous value for short-circuiting or in case we need to revert cmp._dgridLastValue = value; // if this is an editor with editOn, also update _activeValue // (_activeOptions will have been updated previously) if (this._activeCell) { this._activeValue = value; // emit an event immediately prior to placing a shared editor on.emit(cellElement, 'dgrid-editor-show', { grid: this, cell: this.cell(cellElement), column: column, editor: cmp, bubbles: true, cancelable: false }); } }, _startupPendingEditors: function () { var args = this._editorsPendingStartup; for (var i = args.length; i--;) { this._startupEditor.apply(this, args[i]); } this._editorsPendingStartup = []; }, _handleEditorChange: function (evt, column) { var target = evt.target; if ('_dgridLastValue' in target && target.className.indexOf('dgrid-input') > -1) { this._updatePropertyFromEditor(column || this.cell(target).column, target, evt); } }, _createEditor: function (column, object) { // Creates an editor instance based on column definition properties, // and hooks up events. var editor = column.editor, editOn = column.editOn, self = this, Widget = typeof editor !== 'string' && editor, args, cmp, node, tagName, tagArgs = {}; args = column.editorArgs || {}; if (typeof args === 'function') { args = args.call(this, column); } if (Widget) { cmp = new Widget(args); node = cmp.focusNode || cmp.domNode; // Add dgrid-input to className to make consistent with HTML inputs. node.className += ' dgrid-input'; // For editOn editors, connect to onBlur rather than onChange, since // the latter is delayed by setTimeouts in Dijit and will fire too late. cmp.on(editOn ? 'blur' : 'change', function () { if (!cmp._dgridIgnoreChange) { self._updatePropertyFromEditor(column, this, {type: 'widget'}); } }); } else { // considerations for standard HTML form elements if (!this._hasInputListener) { // register one listener at the top level that receives events delegated this._hasInputListener = true; this.on('change', function (evt) { self._handleEditorChange(evt); }); // also register a focus listener } if (editor === 'textarea') { tagName = 'textarea'; } else { tagName = 'input'; tagArgs.type = editor; } cmp = node = domConstruct.create(tagName, lang.mixin(tagArgs, { className: 'dgrid-input', name: column.field, tabIndex: isNaN(column.tabIndex) ? -1 : column.tabIndex }, args)); if (has('ie') < 9) { // IE<9 doesn't fire change events for all the right things, // and it doesn't bubble. var listener; if (editor === 'radio' || editor === 'checkbox') { // listen for clicks since IE doesn't fire change events properly for checks/radios listener = on(cmp, 'click', function (evt) { self._handleEditorChange(evt, column); }); } else { listener = on(cmp, 'change', function (evt) { self._handleEditorChange(evt, column); }); } if (editOn) { // Shared editor handlers are maintained in _editorColumnListeners, since they're not per-row this._editorColumnListeners.push(listener); } else if (this._editorRowListeners) { this._editorRowListeners[column.id] = listener; } // If editRowListeners doesn't exist and this is an always-on editor, // then we're here from renderCell via refreshCell, and the row should exist else { this._editorCellListeners[this.row(object).element.id][column.id] = listener; } } } if (column.autoSelect) { var selectNode = cmp.focusNode || cmp; if (selectNode.select) { on(selectNode, 'focus', function () { // setTimeout is needed for always-on editors on WebKit, // otherwise selection is reset immediately afterwards setTimeout(function () { selectNode.select(); }, 0); }); } } return cmp; }, _createSharedEditor: function (column) { // Creates an editor instance with additional considerations for // shared usage across an entire column (for columns with editOn specified). var cmp = this._createEditor(column), self = this, isWidget = cmp.domNode, node = cmp.domNode || cmp, focusNode = cmp.focusNode || node, reset = isWidget ? function () { cmp.set('value', cmp._dgridLastValue); } : function () { self._updateInputValue(cmp, cmp._dgridLastValue); // Update property again in case we need to revert a previous change self._updatePropertyFromEditor(column, cmp); }; function blur() { var element = self._activeCell; focusNode.blur(); if (typeof self.focus === 'function') { // Dijit form widgets don't end up dismissed until the next turn, // so wait before calling focus (otherwise Keyboard will focus the // input again). IE<9 needs to wait longer, otherwise the cell loses // focus after we've set it. setTimeout(function () { self.focus(element); }, isWidget && has('ie') < 9 ? 15 : 0); } } function onblur() { var parentNode = node.parentNode, options = { alreadyHooked: true }, cell = self.cell(node); // emit an event immediately prior to removing an editOn editor on.emit(cell.element, 'dgrid-editor-hide', { grid: self, cell: cell, column: column, editor: cmp, bubbles: true, cancelable: false }); column._editorBlurHandle.pause(); // Remove the editor from the cell, to be reused later. parentNode.removeChild(node); if (cell.row) { // If the row is still present (i.e. we didn't blur due to removal), // clear out the rest of the cell's contents, then re-render with new value. domClass.remove(cell.element, 'dgrid-cell-editing'); domConstruct.empty(parentNode); Grid.appendIfNode(parentNode, column.renderCell(cell.row.data, self._activeValue, parentNode, self._activeOptions ? lang.delegate(options, self._activeOptions) : options)); } // Reset state now that editor is deactivated; // reset _focusedEditorCell as well since some browsers will not // trigger the focusout event handler in this case self._focusedEditorCell = self._activeCell = self._activeValue = self._activeOptions = null; } function dismissOnKey(evt) { // Contains logic for reacting to enter/escape keypresses to save/cancel edits. // Calls `focusNode.blur()` in cases where field should be dismissed. var key = evt.keyCode || evt.which; if (key === 27) { // Escape: revert + dismiss reset(); self._activeValue = cmp._dgridLastValue; blur(); } else if (key === 13 && column.dismissOnEnter !== false) { // Enter: dismiss blur(); } } // hook up enter/esc key handling this._editorColumnListeners.push(on(focusNode, 'keydown', dismissOnKey)); // hook up blur handler, but don't activate until widget is activated (column._editorBlurHandle = on.pausable(cmp, 'blur', onblur)).pause(); this._editorColumnListeners.push(column._editorBlurHandle); return cmp; }, _updatePropertyFromEditor: function (column, cmp, triggerEvent) { var value, id, editedRow; if (!cmp.isValid || cmp.isValid()) { value = this._updateProperty((cmp.domNode || cmp).parentNode, this._activeCell ? this._activeValue : cmp._dgridLastValue, this._retrieveEditorValue(column, cmp), triggerEvent); if (this._activeCell) { // for editors with editOn defined this._activeValue = value; } else { // for always-on editors, update _dgridLastValue immediately cmp._dgridLastValue = value; } if (cmp.type === 'radio' && cmp.name && !column.editOn && column.field) { editedRow = this.row(cmp); // Update all other rendered radio buttons in the group query('input[type=radio][name=' + cmp.name + ']', this.contentNode).forEach(function (radioBtn) { var row = this.row(radioBtn); // Only update _dgridLastValue and the dirty data if it exists // and is not already false if (radioBtn !== cmp && radioBtn._dgridLastValue) { radioBtn._dgridLastValue = false; if (this.updateDirty) { this.updateDirty(row.id, column.field, false); } else { // update store-less grid row.data[column.field] = false; } } }, this); // Also update dirty data for rows that are not currently rendered for (id in this.dirty) { if (editedRow.id.toString() !== id && this.dirty[id][column.field]) { this.updateDirty(id, column.field, false); } } } } }, _updateProperty: function (cellElement, oldValue, value, triggerEvent) { // Updates dirty hash and fires dgrid-datachange event for a changed value. var self = this; // test whether old and new values are inequal, with coercion (e.g. for Dates) if ((oldValue && oldValue.valueOf()) !== (value && value.valueOf())) { var cell = this.cell(cellElement); var row = cell.row; var column = cell.column; // Re-resolve cellElement in case the passed element was nested cellElement = cell.element; if (column.field && row) { var eventObject = { grid: this, cell: cell, oldValue: oldValue, value: value, bubbles: true, cancelable: true }; if (triggerEvent && triggerEvent.type) { eventObject.parentType = triggerEvent.type; } if (on.emit(cellElement, 'dgrid-datachange', eventObject)) { if (this.updateDirty) { // for OnDemandGrid: update dirty data, and save if autoSave is true this.updateDirty(row.id, column.field, value); // perform auto-save (if applicable) in next tick to avoid // unintentional mishaps due to order of handler execution if (column.autoSave) { setTimeout(function () { self._trackError('save'); }, 0); } } else { // update store-less grid row.data[column.field] = value; } } else { // Otherwise keep the value the same // For the sake of always-on editors, need to manually reset the value var cmp; if ((cmp = cellElement.widget)) { // set _dgridIgnoreChange to prevent an infinite loop in the // onChange handler and prevent dgrid-datachange from firing // a second time cmp._dgridIgnoreChange = true; cmp.set('value', oldValue); setTimeout(function () { cmp._dgridIgnoreChange = false; }, 0); } else if ((cmp = cellElement.input)) { this._updateInputValue(cmp, oldValue); } return oldValue; } } } return value; }, _updateInputValue: function (input, value) { // summary: // Updates the value of a standard input, updating the // checked state if applicable. input.value = value; if (input.type === 'radio' || input.type === 'checkbox') { input.checked = input.defaultChecked = !!value; } }, _retrieveEditorValue: function (column, cmp) { // summary: // Intermediary between _convertEditorValue and // _updatePropertyFromEditor. if (typeof cmp.get === 'function') { // widget return this._convertEditorValue(cmp.get('value')); } else { // HTML input return this._convertEditorValue( cmp[cmp.type === 'checkbox' || cmp.type === 'radio' ? 'checked' : 'value']); } }, _convertEditorValue: function (value, oldValue) { // summary: // Contains default logic for translating values from editors; // tries to preserve type if possible. if (typeof oldValue === 'number') { value = isNaN(value) ? value : parseFloat(value); } else if (typeof oldValue === 'boolean') { value = value === 'true' ? true : value === 'false' ? false : value; } else if (oldValue instanceof Date) { var asDate = new Date(value); value = isNaN(asDate.getTime()) ? value : asDate; } return value; } }); });