define([ 'dojo/_base/declare', 'dojo/_base/lang', 'dojo/Deferred', 'dojo/aspect', 'dojo/dom-construct', 'dojo/has', 'dojo/on', 'dojo/when' ], function (declare, lang, Deferred, aspect, domConstruct, has, on, when) { // This module isolates the base logic required by store-aware list/grid // components, e.g. OnDemandList/Grid and the Pagination extension. function emitError(err) { // called by _trackError in context of list/grid, if an error is encountered if (typeof err !== 'object') { // Ensure we actually have an error object, so we can attach a reference. err = new Error(err); } else if (err.dojoType === 'cancel') { // Don't fire dgrid-error events for errors due to canceled requests // (unfortunately, the Deferred instrumentation will still log them) return; } var event = on.emit(this.domNode, 'dgrid-error', { grid: this, error: err, cancelable: true, bubbles: true }); if (event) { console.error(err); } } return declare(null, { // collection: Object // The base object collection (implementing the dstore/api/Store API) before being sorted // or otherwise processed by the grid. Use it for general purpose store operations such as // `getIdentity` and `get`, `add`, `put`, and `remove`. collection: null, // _renderedCollection: Object // The object collection from which data is to be fetched. This is the sorted collection. // Use it when retrieving data to be rendered by the grid. _renderedCollection: null, // _rows: Array // Sparse array of row nodes, used to maintain the grid in response to events from a tracked collection. // Each node's index corresponds to the index of its data object in the collection. _rows: null, // _observerHandle: Object // The observer handle for the current collection, if trackable. _observerHandle: null, // shouldTrackCollection: Boolean // Whether this instance should track any trackable collection it is passed. shouldTrackCollection: true, // getBeforePut: boolean // If true, a get request will be performed to the store before each put // as a baseline when saving; otherwise, existing row data will be used. getBeforePut: true, // noDataMessage: String // Message to be displayed when no results exist for a collection, whether at // the time of the initial query or upon subsequent observed changes. // Defined by _StoreMixin, but to be implemented by subclasses. noDataMessage: '', // loadingMessage: String // Message displayed when data is loading. // Defined by _StoreMixin, but to be implemented by subclasses. loadingMessage: '', _total: 0, constructor: function () { // Create empty objects on each instance, not the prototype this.dirty = {}; this._updating = {}; // Tracks rows that are mid-update this._columnsWithSet = {}; // Reset _columnsWithSet whenever column configuration is reset aspect.before(this, 'configStructure', lang.hitch(this, function () { this._columnsWithSet = {}; })); }, destroy: function () { this.inherited(arguments); if (this._renderedCollection) { this._cleanupCollection(); } if (this._refreshTimeout) { clearTimeout(this._refreshTimeout); } }, _configColumn: function (column) { // summary: // Implements extension point provided by Grid to store references to // any columns with `set` methods, for use during `save`. if (column.set) { this._columnsWithSet[column.field] = column; } this.inherited(arguments); }, _setCollection: function (collection) { // summary: // Assigns a new collection to the list/grid, sets up tracking // if applicable, and tells the list/grid to refresh. if (this._renderedCollection) { this.cleanup(); this._cleanupCollection({ // Only clear the dirty hash if the collection being used is actually from a different store // (i.e. not just a re-sorted / re-filtered version of the same store) shouldRevert: !collection || collection.storage !== this._renderedCollection.storage }); } this.collection = collection; // Avoid unnecessary rendering and processing before the grid has started up if (this._started) { // Once startup is called, List.startup sets the sort property which calls _StoreMixin._applySort // which sets the collection property again. So _StoreMixin._applySort will be executed again // after startup is called. if (collection) { var renderedCollection = collection; if (this.sort && this.sort.length > 0) { renderedCollection = collection.sort(this.sort); } if (renderedCollection.track && this.shouldTrackCollection) { renderedCollection = renderedCollection.track(); this._rows = []; this._observerHandle = this._observeCollection( renderedCollection, this.contentNode, { rows: this._rows } ); } this._renderedCollection = renderedCollection; } this.refresh(); } }, _setStore: function () { if (!this.collection) { console.debug('set(\'store\') call detected, but you probably meant set(\'collection\')'); } }, _getTotal: function () { // summary: // Retrieves the currently-tracked total (as updated by // subclasses after store queries, or by _StoreMixin in response to // updated totalLength in events) return this._total; }, _cleanupCollection: function (options) { // summary: // Handles cleanup duty for the previous collection; // called during _setCollection and destroy. // options: Object? // * shouldRevert: Whether to clear the dirty hash options = options || {}; if (this._renderedCollection.tracking) { this._renderedCollection.tracking.remove(); } // Remove observer and existing rows so any sub-row observers will be cleaned up if (this._observerHandle) { this._observerHandle.remove(); this._observerHandle = this._rows = null; } // Discard dirty map, as it applied to a previous collection if (options.shouldRevert !== false) { this.dirty = {}; } this._renderedCollection = this.collection = null; }, _applySort: function () { if (this.collection) { this.set('collection', this.collection); } else if (this.store) { console.debug('_StoreMixin found store property but not collection; ' + 'this is often the sign of a mistake during migration from 0.3 to 0.4'); } }, _emitRefreshComplete: function () { // summary: // Handles emitting the dgrid-refresh-complete event on a separate turn, // to enable event to be used consistently regardless of whether the backing store is async. var self = this; this._refreshTimeout = setTimeout(function () { on.emit(self.domNode, 'dgrid-refresh-complete', { bubbles: true, cancelable: false, grid: self }); self._refreshTimeout = null; }, 0); }, _insertNoDataNode: function (parentNode) { // summary: // Creates a node displaying noDataMessage. // Remove the current no data node if it exists. this._removeNoDataNode(); parentNode = parentNode || this.contentNode; var noDataNode = this.noDataNode = domConstruct.create('div', { className: 'dgrid-no-data', innerHTML: this.noDataMessage }); // 2nd param is *required*, even if it is null parentNode.insertBefore(noDataNode, this._getFirstRowSibling ? this._getFirstRowSibling(parentNode) : null); return noDataNode; }, _removeNoDataNode: function () { // summary: // Removes the noDataNode from the grid if it exists. // Returns true if a noDataNode existed previously. // Returns false if no noDataNode existed previously. if (this.noDataNode) { domConstruct.destroy(this.noDataNode); delete this.noDataNode; return true; // Indicate that a noDataNode was removed. } return false; // Indicate there was no noDataNode. }, row: function () { // Extend List#row with more appropriate lookup-by-id logic var row = this.inherited(arguments); if (row && row.data && typeof row.id !== 'undefined') { row.id = this.collection.getIdentity(row.data); } return row; }, refresh: function () { var result = this.inherited(arguments); if (!this.collection) { this._insertNoDataNode(); } return result; }, refreshCell: function (cell) { if (!this.collection || !this._createBodyRowCell) { throw new Error('refreshCell requires a Grid with a collection.'); } this.inherited(arguments); return this.collection.get(cell.row.id).then(lang.hitch(this, '_refreshCellFromItem', cell)); }, _refreshCellFromItem: function (cell, item, options) { var cellElement = cell.element; domConstruct.empty(cellElement); var dirtyItem = this.dirty && this.dirty[cell.row.id]; if (dirtyItem) { item = lang.delegate(item, dirtyItem); } this._createBodyRowCell(cellElement, cell.column, item, options); }, renderArray: function () { var rows = this.inherited(arguments); if (!this.collection) { if (rows.length && this.noDataNode) { domConstruct.destroy(this.noDataNode); } } return rows; }, insertRow: function (object, parent, beforeNode, i, options) { var store = this.collection, dirty = this.dirty, id = store && store.getIdentity(object), dirtyObj, row; if (id in dirty && !(id in this._updating)) { dirtyObj = dirty[id]; } if (dirtyObj) { // restore dirty object as delegate on top of original object, // to provide protection for subsequent changes as well object = lang.delegate(object, dirtyObj); } row = this.inherited(arguments); if (options && options.rows) { options.rows[i] = row; } // Remove no data message when a new row appears. // Run after inherited logic to prevent confusion due to noDataNode // no longer being present as a sibling. if (this.noDataNode) { domConstruct.destroy(this.noDataNode); this.noDataNode = null; } return row; }, updateDirty: function (id, field, value) { // summary: // Updates dirty data of a field for the item with the specified ID. var dirty = this.dirty, dirtyObj = dirty[id]; if (!dirtyObj) { dirtyObj = dirty[id] = {}; } dirtyObj[field] = value; }, save: function () { // Keep track of the store and puts var self = this, store = this.collection, dirty = this.dirty, dfd = new Deferred(), results = {}, getFunc = function (id) { // returns a function to pass as a step in the promise chain, // with the id variable closured var data; return (self.getBeforePut || !(data = self.row(id).data)) ? function () { return store.get(id); } : function () { return data; }; }; // function called within loop to generate a function for putting an item function putter(id, dirtyObj) { // Return a function handler return function (object) { var colsWithSet = self._columnsWithSet, updating = self._updating, key, data; if (typeof object.set === 'function') { object.set(dirtyObj); } else { // Copy dirty props to the original, applying setters if applicable for (key in dirtyObj) { object[key] = dirtyObj[key]; } } // Apply any set methods in column definitions. // Note that while in the most common cases column.set is intended // to return transformed data for the key in question, it is also // possible to directly modify the object to be saved. for (key in colsWithSet) { data = colsWithSet[key].set(object); if (data !== undefined) { object[key] = data; } } updating[id] = true; // Put it in the store, returning the result/promise return store.put(object).then(function (result) { // Clear the item now that it's been confirmed updated delete dirty[id]; delete updating[id]; results[id] = result; return results; }); }; } var promise = dfd.then(function () { // Ensure empty object is returned even if nothing was dirty, for consistency return results; }); // For every dirty item, grab the ID for (var id in dirty) { // Create put function to handle the saving of the the item var put = putter(id, dirty[id]); // Add this item onto the promise chain, // getting the item from the store first if desired. promise = promise.then(getFunc(id)).then(put); } // Kick off and return the promise representing all applicable get/put ops. // If the success callback is fired, all operations succeeded; otherwise, // save will stop at the first error it encounters. dfd.resolve(); return promise; }, revert: function () { // summary: // Reverts any changes since the previous save. this.dirty = {}; this.refresh(); }, _trackError: function (func) { // summary: // Utility function to handle emitting of error events. // func: Function|String // A function which performs some store operation, or a String identifying // a function to be invoked (sans arguments) hitched against the instance. // If sync, it can return a value, but may throw an error on failure. // If async, it should return a promise, which would fire the error // callback on failure. // tags: // protected if (typeof func === 'string') { func = lang.hitch(this, func); } var self = this, promise; try { promise = when(func()); } catch (err) { // report sync error var dfd = new Deferred(); dfd.reject(err); promise = dfd.promise; } promise.otherwise(function (err) { emitError.call(self, err); }); return promise; }, removeRow: function (rowElement, preserveDom, options) { var row = {element: rowElement}; // Check to see if we are now empty... if (!preserveDom && (this.up(row).element === rowElement) && (this.down(row).element === rowElement)) { // ...we are empty, so show the no data message. this._insertNoDataNode(); } var rows = (options && options.rows) || this._rows; if (rows) { delete rows[rowElement.rowIndex]; } return this.inherited(arguments); }, renderQueryResults: function (results, beforeNode, options) { // summary: // Renders objects from QueryResults as rows, before the given node. options = lang.mixin({ rows: this._rows }, options); var self = this; if (!has('dojo-built')) { // Check for null/undefined totalResults to help diagnose faulty services/stores results.totalLength.then(function (total) { if (total == null) { console.warn('Store reported null or undefined totalLength. ' + 'Make sure your store (and service, if applicable) are reporting total correctly!'); } }); } return results.then(function (resolvedResults) { var resolvedRows = self.renderArray(resolvedResults, beforeNode, options); delete self._lastCollection; // used only for non-store List/Grid return resolvedRows; }); }, _observeCollection: function (collection, container, options) { var self = this, rows = options.rows, row; var handles = [ collection.on('delete, update', function (event) { var from = event.previousIndex; var to = event.index; if (from !== undefined && rows[from]) { if ('max' in rows && (to === undefined || to < rows.min || to > rows.max)) { rows.max--; } row = rows[from]; // check to make the sure the node is still there before we try to remove it // (in case it was moved to a different place in the DOM) if (row.parentNode === container) { self.removeRow(row, false, options); } // remove the old slot rows.splice(from, 1); if (event.type === 'delete' || (event.type === 'update' && (from < to || to === undefined))) { // adjust the rowIndex so adjustRowIndices has the right starting point rows[from] && rows[from].rowIndex--; } } if (event.type === 'delete') { // Reset row in case this is later followed by an add; // only update events should retain the row variable below row = null; } }), collection.on('add, update', function (event) { var from = event.previousIndex; var to = event.index; var nextNode; function advanceNext() { nextNode = (nextNode.connected || nextNode).nextSibling; } // When possible, restrict observations to the actually rendered range if (to !== undefined && (!('max' in rows) || (to >= rows.min && to <= rows.max))) { if ('max' in rows && (from === undefined || from < rows.min || from > rows.max)) { rows.max++; } // Add to new slot (either before an existing row, or at the end) // First determine the DOM node that this should be placed before. if (rows.length) { nextNode = rows[to]; if (!nextNode) { nextNode = rows[to - 1]; if (nextNode) { // Make sure to skip connected nodes, so we don't accidentally // insert a row in between a parent and its children. advanceNext(); } } } else { // There are no rows. Allow for subclasses to insert new rows somewhere other than // at the end of the parent node. nextNode = self._getFirstRowSibling && self._getFirstRowSibling(container); } // Make sure we don't trip over a stale reference to a // node that was removed, or try to place a node before // itself (due to overlapped queries) if (row && nextNode && row.id === nextNode.id) { advanceNext(); } if (nextNode && !nextNode.parentNode) { nextNode = document.getElementById(nextNode.id); } rows.splice(to, 0, undefined); row = self.insertRow(event.target, container, nextNode, to, options); self.highlightRow(row); } // Reset row so it doesn't get reused on the next event row = null; }), collection.on('add, delete, update', function (event) { var from = (typeof event.previousIndex !== 'undefined') ? event.previousIndex : Infinity, to = (typeof event.index !== 'undefined') ? event.index : Infinity, adjustAtIndex = Math.min(from, to); from !== to && rows[adjustAtIndex] && self.adjustRowIndices(rows[adjustAtIndex]); // the removal of rows could cause us to need to page in more items if (from !== Infinity && self._processScroll && (rows[from] || rows[from - 1])) { self._processScroll(); } // Fire _onNotification, even for out-of-viewport notifications, // since some things may still need to update (e.g. Pagination's status/navigation) self._onNotification(rows, event, collection); // Update _total after _onNotification so that it can potentially // decide whether to perform actions based on whether the total changed if (collection === self._renderedCollection && 'totalLength' in event) { self._total = event.totalLength; } }) ]; return { remove: function () { while (handles.length > 0) { handles.pop().remove(); } } }; }, _onNotification: function () { // summary: // Protected method called whenever a store notification is observed. // Intended to be extended as necessary by mixins/extensions. // rows: Array // A sparse array of row nodes corresponding to data objects in the collection. // event: Object // The notification event // collection: Object // The collection that the notification is relevant to. // Useful for distinguishing child-level from top-level notifications. } }); });