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.
|
}
|
});
|
});
|