"use strict";
/**
* @module gallery-bulkedit
*/
/**********************************************************************
* <p>BulkEditDataSource manages a YUI DataSource + diffs (insertions,
* removals, changes to values).</p>
*
* <p>The YUI DataSource must be immutable, e.g., if it is an XHR
* datasource, the data must not change.</p>
*
* <p>By using a DataSource, we can support both client-side pagination
* (all data pre-loaded, best-effort save allowed) and server-side
* pagination (load data when needed, only all-or-nothing save allowed).
* Server-side pagination is useful when editing a large amount of existing
* records or after uploading a large number of new records. (Store the new
* records in a scratch space, so everything does not have to be sent back
* to the client after parsing.) In the case of bulk upload, server-side
* validation will catch errors in unviewed records.</p>
*
* <p>The responseSchema passed to the YUI DataSource must include a
* comparator for each field that should not be treated like a string.
* This comparator can either be 'string' (the default), 'integer',
* 'decimal', 'boolean', or a function which takes two arguments.</p>
*
* @class BulkEdit
* @namespace DataSource
* @extends DataSource.Local
* @constructor
* @param config {Object}
*/
function BulkEditDataSource()
{
BulkEditDataSource.superclass.constructor.apply(this, arguments);
}
BulkEditDataSource.NAME = "bulkEditDataSource";
BulkEditDataSource.ATTRS =
{
/**
* The original data. This must be immutable, i.e., the values must
* not change.
*
* @attribute ds
* @type {DataSource}
* @required
* @writeonce
*/
ds:
{
writeOnce: true
},
/**
* The function to convert the initial request into a request usable by
* the underlying DataSource. This function takes one argument: state
* (startIndex,resultCount,...).
*
* @attribute generateRequest
* @type {Function}
* @required
* @writeonce
*/
generateRequest:
{
validator: Y.Lang.isFunction,
writeOnce: true
},
/**
* The name of the key in each record that stores an identifier which
* is unique across the entire data set.
*
* @attribute uniqueIdKey
* @type {String}
* @required
* @writeonce
*/
uniqueIdKey:
{
validator: Y.Lang.isString,
writeOnce: true
},
/**
* The function to call to generate a unique id for a new record. The
* default generates "bulk-edit-new-id-#".
*
* @attribute generateUniqueId
* @type {Function}
* @writeonce
*/
generateUniqueId:
{
value: function()
{
idCounter++;
return uniqueIdPrefix + idCounter;
},
validator: Y.Lang.isFunction,
writeOnce: true
},
/**
* OGNL expression telling how to extract the startIndex from the
* received data, e.g., <code>.meta.startIndex</code>. If it is not
* provided, startIndex is always assumed to be zero.
*
* @attribute startIndexExpr
* @type {String}
* @writeonce
*/
startIndexExpr:
{
validator: Y.Lang.isString,
writeOnce: true
},
/**
* OGNL expression telling where in the response to store the total
* number of records, e.g., <code>.meta.totalRecords</code>. This is
* only appropriate for DataSources that always return the entire data
* set.
*
* @attribute totalRecordsReturnExpr
* @type {String}
* @writeonce
*/
totalRecordsReturnExpr:
{
validator: Y.Lang.isString,
writeOnce: true
},
/**
* The function to call to extract the total number of
* records from the response.
*
* @attribute extractTotalRecords
* @type {Function}
* @required
* @writeonce
*/
extractTotalRecords:
{
validator: Y.Lang.isFunction,
writeOnce: true
}
};
var uniqueIdPrefix = 'bulk-edit-new-id-',
idCounter = 0,
inserted_prefix = 'be-ds-i:',
inserted_re = /^be-ds-i:/,
removed_prefix = 'be-ds-r:',
removed_re = /^be-ds-r:/,
hidden_prefix = 'be-ds-h:',
hidden_re = /^be-ds-h:/,
removed_hidden_re = /^be-ds-[rh]:/;
BulkEditDataSource.comparator =
{
'string': function(a,b)
{
return (a.toString() === b.toString());
},
'integer': function(a,b)
{
return (Y.FormManager.integer_value_re.test(a) &&
Y.FormManager.integer_value_re.test(b) &&
parseInt(a,10) === parseInt(b,10));
},
'decimal': function(a,b)
{
return (Y.FormManager.decimal_value_re.test(a) &&
Y.FormManager.decimal_value_re.test(b) &&
parseFloat(a,10) === parseFloat(b,10));
},
'boolean': function(a,b)
{
return (((a && b) || (!a && !b)) ? true : false);
}
};
function fromDisplayIndex(
/* int */ index)
{
var count = -1;
for (var i=0; i<this._index.length; i++)
{
var j = this._index[i];
if (!removed_hidden_re.test(j))
{
count++;
if (count === index)
{
return i;
}
}
}
return false;
}
function adjustRequest()
{
var r = this._callback.request;
this._callback.adjust =
{
origStart: r.startIndex,
origCount: r.resultCount
};
if (!this._index)
{
return;
}
// find index of first record to request
var start = Math.min(r.startIndex, this._index.length);
var count = 0;
for (var i=0; i<start; i++)
{
var j = this._index[i];
if (!inserted_re.test(j))
{
count++;
}
if (removed_hidden_re.test(j))
{
start++;
}
}
r.startIndex = count;
this._callback.adjust.indexStart = i;
// adjust number of records to request
count = 0;
while (i < this._index.length && count < this._callback.adjust.origCount)
{
var j = this._index[i];
if (inserted_re.test(j))
{
r.resultCount--;
}
if (removed_hidden_re.test(j))
{
r.resultCount++;
}
else
{
count++;
}
i++;
}
this._callback.adjust.indexEnd = i;
}
function internalSuccess(e)
{
if (!e.response || e.error ||
!Y.Lang.isArray(e.response.results))
{
internalFailure.apply(this, arguments);
return;
}
// synch response arrives before setting _tId
if (!Y.Lang.isUndefined(this._callback._tId) &&
e.tId !== this._callback._tId)
{
return; // cancelled request
}
this._callback.response = e.response;
checkFinished.call(this);
}
function internalFailure(e)
{
if (e.tId === this._callback._tId)
{
this._callback.error = e.error;
this._callback.response = e.response;
this.fire('response', this._callback);
}
}
function checkFinished()
{
if (this._generatingRequest || !this._callback.response)
{
return;
}
if (!this._fields)
{
this._fields = {};
Y.Array.each(this.get('ds').schema.get('schema').resultFields, function(value)
{
if (Y.Lang.isObject(value))
{
this._fields[ value.key ] = value;
}
},
this);
}
var response = {};
Y.mix(response, this._callback.response);
response.results = [];
response = Y.clone(response, true);
var dataStartIndex = 0;
if (this.get('startIndexExpr'))
{
dataStartIndex = Y.Object.evalGet(this._callback.response, this.get('startIndexExpr'));
}
var startIndex = this._callback.request.startIndex - dataStartIndex;
response.results = this._callback.response.results.slice(startIndex, startIndex + this._callback.request.resultCount);
// insertions/removals
if (!this._index)
{
if (this.get('totalRecordsReturnExpr'))
{
Y.Object.evalSet(response, this.get('totalRecordsReturnExpr'), this._callback.response.results.length);
}
this._count = this.get('extractTotalRecords')(response);
this._index = [];
for (var i=0; i<this._count; i++)
{
this._index.push(i);
}
}
else
{
var adjust = this._callback.adjust;
for (var i=adjust.indexStart, k=0; i<adjust.indexEnd; i++, k++)
{
var j = this._index[i];
if (inserted_re.test(j))
{
var id = j.substr(inserted_prefix.length);
response.results.splice(k,0, Y.clone(this._new[id], true));
}
else if (removed_hidden_re.test(j))
{
response.results.splice(k,1);
k--;
}
}
}
// save results so we can refer to them later
var unique_id_key = this.get('uniqueIdKey');
if (!this._callback.request.outOfBand)
{
this._records = [];
this._recordMap = {};
Y.Array.each(response.results, function(r)
{
var rec = Y.clone(r, true);
this._records.push(rec);
this._recordMap[ rec[ unique_id_key ] ] = rec;
},
this);
}
// merge in diffs
Y.Array.each(response.results, function(rec)
{
var diff = this._diff[ rec[ unique_id_key ] ];
if (diff)
{
Y.mix(rec, diff, true);
}
},
this);
this._callback.response = response;
this.fire('response', this._callback);
}
Y.extend(BulkEditDataSource, Y.DataSource.Local,
{
initializer: function(config)
{
if (!(config.ds instanceof Y.DataSource.Local))
{
Y.error('BulkEditDataSource requires DataSource');
}
if (!config.generateRequest)
{
Y.error('BulkEditDataSource requires generateRequest function');
}
if (!config.uniqueIdKey)
{
Y.error('BulkEditDataSource requires uniqueIdKey configuration');
}
if (!config.extractTotalRecords)
{
Y.error('BulkEditDataSource requires extractTotalRecords function');
}
this._index = null;
this._count = 0;
this._new = {};
this._diff = {};
},
/**
* @method _dataIsLocal
* @protected
* @return {boolean} true if the raw data is stored locally
*/
_dataIsLocal: function()
{
return (Y.Lang.isArray(this.get('ds').get('source')));
},
/**
* Flush the underlying datasource's cache.
*
* @method _flushCache
* @protected
*/
_flushCache: function()
{
var ds = this.get('ds');
if (ds.cache && Y.Lang.isFunction(ds.cache.flush))
{
ds.cache.flush();
}
},
/**
* Use this instead of any meta information in response.
*
* @method getRecordCount
* @return {Number} the total number of visible records
*/
getRecordCount: function()
{
return this._count;
},
/**
* @method getCurrentRecords
* @return {Number} the records returned by the latest request
*/
getCurrentRecords: function()
{
return this._records;
},
/**
* @method getCurrentRecordMap
* @return {Object} the records returned by the latest request, keyed by record id
*/
getCurrentRecordMap: function()
{
return this._recordMap;
},
/**
* @method getValue
* @param record_index {Number}
* @param key {String} field key
* @return {mixed} the value of the specified field in the specified record
*/
getValue: function(
/* int */ record_index,
/* string */ key)
{
if (!this._dataIsLocal())
{
Y.error('BulkEditDataSource.getValue() can only be called when using a local datasource');
}
var j = fromDisplayIndex.call(this, record_index);
if (j === false)
{
return false;
}
j = this._index[j];
if (inserted_re.test(j))
{
var record_id = j.substr(inserted_prefix.length);
var record = this._new[ record_id ];
}
else
{
var record = this.get('ds').get('source')[j];
var record_id = record[ this.get('uniqueIdKey') ];
}
if (this._diff[ record_id ] &&
!Y.Lang.isUndefined(this._diff[ record_id ][ key ]))
{
return this._diff[ record_id ][ key ];
}
else
{
return record[key];
}
},
/**
* When using a remote datasource, this will include changes made to
* deleted records.
*
* @method getChanges
* @return {Object} map of all changed values, keyed by record id
*/
getChanges: function()
{
return this._diff;
},
/**
* @method getRemovedRecordIndexes
* @return {Array} list of removed record indices, based on initial ordering
*/
getRemovedRecordIndexes: function()
{
var list = [];
Y.Array.each(this._index, function(j)
{
if (removed_re.test(j))
{
list.push(parseInt(j.substr(removed_prefix.length), 10));
}
});
return list;
},
/**
* @method getHiddenRecordIndexes
* @return {Array} list of hidden record indices, based on initial ordering
*/
getHiddenRecordIndexes: function()
{
var list = [];
Y.Array.each(this._index, function(j)
{
if (hidden_re.test(j))
{
list.push(parseInt(j.substr(hidden_prefix.length), 10));
}
});
return list;
},
/**
* You must `reload` the widget after calling this function!
*
* @method insertRecord
* @protected
* @param index {Number} insertion index
* @param record {Object|String} record to insert or id of record to clone
* @return {String} id of newly inserted record
*/
insertRecord: function(
/* int */ index,
/* object */ record)
{
this._count++;
var record_id = String(this.get('generateUniqueId')()),
unique_id_key = this.get('uniqueIdKey');
this._new[ record_id ] = {};
this._new[ record_id ][ unique_id_key ] = record_id;
var j = fromDisplayIndex.call(this, index);
if (j === false)
{
j = this._index.length;
}
this._index.splice(j, 0, inserted_prefix+record_id);
if (record && !Y.Lang.isObject(record)) // clone existing record
{
var s = record.toString();
record = Y.clone(this._recordMap[s] || this._new[s], true);
var diff = this._diff[s];
if (record && diff)
{
Y.mix(record, diff, true);
}
}
if (record) // insert initial values into _diff
{
Y.Object.each(record, function(value, key)
{
if (key != unique_id_key)
{
this.updateValue(record_id, key, value);
}
},
this);
}
return record_id;
},
/**
* You must `reload` the widget after calling this function!
*
* @method removeRecord
* @protected
* @param index {Number} index of record to remove
* @return {boolean} true if record was removed
*/
removeRecord: function(
/* int */ index)
{
var j = fromDisplayIndex.call(this, index);
if (j === false)
{
return false;
}
this._count--;
if (inserted_re.test(this._index[j]))
{
var record_id = this._index[j].substr(inserted_prefix.length);
delete this._new[ record_id ];
this._index.splice(j,1);
}
else
{
if (this._dataIsLocal())
{
var record_id = this.get('ds').get('source')[ this._index[j] ][ this.get('uniqueIdKey') ].toString();
}
this._index[j] = removed_prefix + this._index[j];
}
if (record_id)
{
delete this._diff[ record_id ];
}
return true;
},
/**
* Inverse of `removeRecord`, but only for pre-existing records and
* only with `DataSource.Local`. Changes made before the record was
* removed are not restored.
*
* You must `reload` the widget after calling this function!
*
* @method restoreRecord
* @protected
* @param record_id {String}
* @return {int} the restored record's index or -1 if record id is not found
*/
restoreRecord: function(
/* string */ record_id)
{
return this._restoreRecord(record_id, removed_re, removed_prefix, 'restoreRecord');
},
/**
* Update a value in a record.
*
* @method updateValue
* @protected
* @param record_id {String}
* @param key {String} field key
* @param value {String} new item value
* @param [set_as_default] {boolean} pass `true` to make this the new default value - must call editor.reload() afterwards!
*/
updateValue: function(
/* string */ record_id,
/* string */ key,
/* string */ value,
/* bool */ set_as_default)
{
if (key == this.get('uniqueIdKey'))
{
Y.error('BulkEditDataSource.updateValue() does not allow changing the id for a record. Use BulkEditDataSource.updateRecordId() instead.');
}
if (set_as_default && !this._dataIsLocal())
{
Y.error('BulkEditDataSource.updateValue() can only be called with set_as_default=true when using a local datasource');
}
record_id = record_id.toString();
var record = this._recordMap[ record_id ];
function removeFromDiff()
{
if (this._diff[ record_id ])
{
delete this._diff[ record_id ][ key ];
if (Y.Object.keys(this._diff[ record_id ]).length === 0)
{
delete this._diff[ record_id ];
}
}
}
if (set_as_default)
{
var unique_id_key = this.get('uniqueIdKey');
Y.Array.some(this.get('ds').get('source'), function(rec)
{
if (rec[ unique_id_key ].toString() === record_id)
{
rec[ key ] = value;
removeFromDiff.call(this);
return true;
}
},
this);
}
else if (record && this._getComparator(key)(Y.Lang.isValue(record[key]) ? record[key] : '', Y.Lang.isValue(value) ? value : ''))
{
removeFromDiff.call(this);
}
else // might be new record
{
if (!this._diff[ record_id ])
{
this._diff[ record_id ] = {};
}
this._diff[ record_id ][ key ] = value;
}
},
/**
* @method _getComparator
* @protected
* @param key {String} field key
* @return {Function} comparator function for the given field
*/
_getComparator: function(
/* string */ key)
{
var f = (this._fields[key] && this._fields[key].comparator) || 'string';
if (Y.Lang.isFunction(f))
{
return f;
}
else if (BulkEditDataSource.comparator[f])
{
return BulkEditDataSource.comparator[f];
}
else
{
return BulkEditDataSource.comparator.string;
}
},
/**
* Merge changes into the underlying data, to flush diffs for a record.
* Only usable with DataSource.Local. When using best-effort save on
* the server, call this for each record that was successfully saved.
*
* @method mergeChanges
* @param record_id {String}
*/
mergeChanges: function(
/* string */ record_id)
{
if (!this._dataIsLocal())
{
Y.error('BulkEditDataSource.mergeChanges() can only be called when using a local datasource');
}
record_id = record_id.toString();
var unique_id_key = this.get('uniqueIdKey');
function merge(rec)
{
if (rec[ unique_id_key ].toString() === record_id)
{
var diff = this._diff[ record_id ];
if (diff)
{
Y.mix(rec, diff, true);
delete this._diff[ record_id ];
}
return true;
}
}
var found = false;
this._flushCache();
Y.Array.some(this.get('ds').get('source'), function(rec)
{
if (merge.call(this, rec))
{
found = true;
return true;
}
},
this);
if (!found)
{
Y.Object.some(this._new, function(rec)
{
if (merge.call(this, rec))
{
found = true;
return true;
}
},
this);
}
},
/**
* <p>Completely remove a record, from both the display and the
* underlying data. Only usable with DataSource.Local. When using
* best-effort save on the server, call this for each record that was
* successfully deleted.</p>
*
* <p>You must `reload` the widget after calling this function!</p>
*
* @method killRecord
* @param record_id {String}
*/
killRecord: function(
/* string */ record_id)
{
if (!this._dataIsLocal())
{
Y.error('BulkEditDataSource.killRecord() can only be called when using a local datasource');
}
record_id = record_id.toString();
var unique_id_key = this.get('uniqueIdKey');
function kill(rec)
{
if (rec[ unique_id_key ].toString() === record_id)
{
var info = {};
this.recordIdToIndex(record_id, info);
var j = this._index[ info.internal_index ];
this._index.splice(info.internal_index, 1);
if (!inserted_re.test(j))
{
for (var i=info.internal_index; i<this._index.length; i++)
{
var k = this._index[i];
if (removed_re.test(k))
{
this._index[i] = removed_prefix +
(parseInt(k.substr(removed_prefix.length), 10)-1);
}
else if (hidden_re.test(k))
{
this._index[i] = hidden_prefix +
(parseInt(k.substr(hidden_prefix.length), 10)-1);
}
else if (!inserted_re.test(k))
{
this._index[i]--;
}
}
}
this._count--;
delete this._diff[ record_id ];
return true;
}
}
var found = false;
this._flushCache();
var data = this.get('ds').get('source');
Y.Array.some(data, function(value, i)
{
if (kill.call(this, value))
{
data.splice(i,1);
found = true;
return true;
}
},
this);
if (!found)
{
Y.Object.some(this._new, function(value, id)
{
if (kill.call(this, value))
{
delete this._new[id];
found = true;
return true;
}
},
this);
}
},
/**
* <p>Change the id of a record. Only usable with DataSource.Local.
* When using best-effort save on the server, call this for each newly
* created record that was successfully saved.</p>
*
* <p>You must `reload` the widget after calling this function!</p>
*
* @method updateRecordId
* @param orig_record_id {String}
* @param new_record_id {String}
*/
updateRecordId: function(
/* string */ orig_record_id,
/* string */ new_record_id)
{
if (!this._dataIsLocal())
{
Y.error('BulkEditDataSource.updateRecordId() can only be called when using a local datasource');
}
orig_record_id = orig_record_id.toString();
new_record_id = new_record_id.toString();
var unique_id_key = this.get('uniqueIdKey');
function update(rec)
{
if (rec[ unique_id_key ].toString() === orig_record_id)
{
var info = {};
this.recordIdToIndex(orig_record_id, info);
var j = info.internal_index;
if (inserted_re.test(this._index[j]))
{
this._index[j] = inserted_prefix + new_record_id;
}
rec[ unique_id_key ] = new_record_id;
if (this._diff[ orig_record_id ])
{
this._diff[ new_record_id ] = this._diff[ orig_record_id ];
delete this._diff[ orig_record_id ];
}
return true;
}
}
var found = false;
this._flushCache();
Y.Array.some(this.get('ds').get('source'), function(value)
{
if (update.call(this, value))
{
found = true;
return true;
}
},
this);
if (!found)
{
Y.Object.some(this._new, function(value, id)
{
if (update.call(this, value))
{
this._new[ new_record_id ] = value;
delete this._new[id];
found = true;
return true;
}
},
this);
}
},
/**
* Only usable with `DataSource.Local`.
*
* You must `reload` the widget after calling this function!
*
* @method showRecord
* @protected
* @param record_id {String}
* @return {int} the newly visible record's index or -1 if record id is not found
*/
showRecord: function(
/* string */ record_id)
{
return this._restoreRecord(record_id, hidden_re, hidden_prefix, 'showRecord');
},
/**
* Only usable with `DataSource.Local`.
*
* You must `reload` the widget after calling this function!
*
* You can only hide pre-existing, unmodified records. Otherwise, you
* might get a validation error on a hidden record.
*
* @method hideRecord
* @protected
* @param index {Number} index of record to hide
* @return {boolean} true if record is now hidden
*/
hideRecord: function(
/* int */ index)
{
if (!this._dataIsLocal())
{
Y.error('BulkEditDataSource.hideRecord() can only be called when using a local datasource');
}
var j = fromDisplayIndex.call(this, index);
if (j === false || inserted_re.test(this._index[j]))
{
return false;
}
var record_id = this.get('ds').get('source')[ this._index[j] ][ this.get('uniqueIdKey') ].toString();
if (this._diff[ record_id ])
{
return false;
}
this._count--;
this._index[j] = hidden_prefix + this._index[j];
return true;
},
/**
* Only usable with `DataSource.Local`.
*
* You must `reload` the widget after calling this function!
*
* @method _restoreRecord
* @private
* @param record_id {String}
* @return {int} the newly visible record's index or -1 if record id is not found
*/
_restoreRecord: function(
/* string */ record_id,
/* RegExp */ re,
/* string */ prefix,
/* string */ caller_name)
{
if (!this._dataIsLocal())
{
Y.error('BulkEditDataSource.' + caller_name + '() can only be called when using a local datasource');
}
record_id = record_id.toString();
var unique_id_key = this.get('uniqueIdKey');
var records = this.get('ds').get('source');
var count = 0;
for (var i=0; i<this._index.length; i++)
{
var j = this._index[i];
if (re.test(j))
{
var k = j.substr(prefix.length);
if (records[k][ unique_id_key ].toString() === record_id)
{
this._count++;
this._index[i] = k;
return count;
}
}
if (!removed_re.test(j))
{
count++;
}
}
return -1;
},
/**
* Find the index of the given record id. Only usable with
* `DataSource.Local`.
*
* @method recordIdToIndex
* @param record_id {String}
* @return {Number} index of record or -1 if not found
*/
recordIdToIndex: function(
/* string */ record_id,
/* object */ return_info)
{
if (!this._dataIsLocal())
{
Y.error('BulkEditDataSource.recordIdToIndex() can only be called when using a local datasource');
}
record_id = record_id.toString();
var unique_id_key = this.get('uniqueIdKey');
var records = this.get('ds').get('source');
var count = 0;
for (var i=0; i<this._index.length; i++)
{
var j = this._index[i];
var ins = inserted_re.test(j);
var del = removed_hidden_re.test(j);
if ((ins &&
j.substr(inserted_prefix.length) === record_id) ||
(!ins && !del &&
records[j][ unique_id_key ].toString() === record_id))
{
if (return_info)
{
return_info.internal_index = i;
}
return count;
}
if (!del)
{
count++;
}
}
return -1;
},
/**
* Merges edits into data and returns result.
*
* @method _defRequestFn
* @protected
*/
_defRequestFn: function(e)
{
this._callback = e;
adjustRequest.call(this);
this._generatingRequest = true;
delete this._callback._tId; // clear it so internalSuccess works for synch response
this._callback._tId = this.get('ds').sendRequest(
{
request: this.get('generateRequest')(this._callback.request),
callback:
{
success: Y.bind(internalSuccess, this),
failure: Y.bind(internalFailure, this)
}
});
this._generatingRequest = false;
checkFinished.call(this);
}
});
Y.BulkEditDataSource = BulkEditDataSource;
Y.namespace('DataSource').BulkEdit = BulkEditDataSource;