UNPKG

ares-ide

Version:

A browser-based code editor and UI designer for Enyo 2 projects

774 lines (761 loc) 29.2 kB
//*@public /** _enyo.Collection_ is an array-like structure that houses collections of [enyo.Model](#enyo.Model) instances. Collections are read-only entities in terms of retrieving and setting data via an [enyo.Source](#enyo.Source). Like _enyo.Model_, _enyo.Collection_ has a separate and distinct non- bubbling notification API. Collection objects generate _add_, _remove_, _reset_ and _destroy_ events that may be listened for using the _addListener()_ method. A collection lazily instantiates records when they are requested. This is important to keep in mind with respect to the order of operations. */ enyo.kind({ name: "enyo.Collection", //*@protected kind: enyo.Component, noDefer: true, mixins: [enyo.RegisteredEventSupport], //*@public /** The kind of records the collection will house. By default, it is simply _enyo.Model_, but it may be set to any kind of model. */ model: enyo.Model, /** The correct URL for requesting data for this collection. */ url: "", /** By default, collections instantiate records only as needed; set this flag to true if you want records to be created as soon as as they are added to the collection */ instanceAllRecords: false, /** The default source for requests made by this collection */ defaultSource: "ajax", /** The underlying array that stores the records for this collection. Modifying this array may have undesirable effects. */ records: null, /** True if the collection is currently facading data as a filtered dataset; otherwise, false. */ filtered: false, /** Collections that need to temporarily filter their data based on events or other criteria may use this object to map a filter name to a filter method on the collection that will be called in the context of the collection when the filter name is set as the _activeFilter_ property. These methods should return the array they wish to have the collection reset to, true to force a reset, or any falsey value to do nothing. Note that you can call _reset()_ within the filter, but no _reset_ event will be emitted. */ filters: null, /** This is an array or space-delimited string of properties that, when updated from bindings or via the _set()_ method, will trigger a _filter_ event on the collection automatically if an _activeFilter_ is active. */ filterProps: "", /** A string that names the current filter from the _filters_ property to apply (or that is being applied) to the collection. Setting this value will automatically trigger the filter method. If a filter is set, it will be run any time new records are added to the collection. You can also force the collection to filter its content according to the _activeFilter_ by calling _triggerEvent("filter")_. */ activeFilter: "", /** Preserve records generated by this collection, even if the collection is destroyed. By default, they will also be destroyed. */ preserveRecords: false, /** All collections have a store reference. You may set this to a specific store instance in your application or use the default (the _enyo.store_ global). */ store: null, /** The number of records in the collection */ length: 0, /** Fetches the data for this collection. Accepts options with optional callbacks, _success_ and _fail_, the _source_ (if not specified, the _defaultSource_ for the kind will be used), and the _replace_ flag. If _replace_ is true, all current records in the collection will be removed (though not destroyed) before adding any results. If this is the case, the method will return an array of any records that were removed. The options may include a _strategy_ for how received data is added to the collection. The _"add"_ strategy (the default) is most efficient; it places each incoming record at the end of the collection. The _"merge"_ strategy will make the collection attempt to identify existing records with the same _primaryKey_ as the incoming one, updating any matching records. When using the _add_ strategy, if incoming data from _fetch()_ belongs to a record already in the collection, this record will be duplicated and have a unique _euid_. This method will call _reset()_ if any filters have been applied to the collection. */ fetch: function (opts) { if (this.filtered) { this.reset(); } var o = opts? enyo.clone(opts): {}; // ensure there is a strategy for the _didFetch_ method (opts = opts || {}) && (opts.strategy = opts.strategy || "add"); o.success = enyo.bindSafely(this, "didFetch", this, opts); o.fail = enyo.bindSafely(this, "didFail", "fetch", this, opts); // now if we need to lets remove the records and attempt to do this // while any possible asynchronous remote (not always remote...) calls // are made for efficiency enyo.asyncMethod(this, function () { this.store.fetchRecord(this, o); }); if (o.replace && !o.destroy) { this.removeAll(); } else if (o.destroy) { this.destroyAll(); } }, /** Convenience method that does not require the callee to set the _replace_ parameter in the passed-in options. */ fetchAndReplace: function (opts) { var o = opts || {}; o.replace = true; return this.fetch(o); }, /** Convenience method that does not require the callee to set the _destroy_ parameter in the passed-in options. */ fetchAndDestroy: function (opts) { var o = opts || {}; o.destroy = true; return this.fetch(o); }, /** This method is executed after a successful fetch, asynchronously. Any new data either replaces or is merged with the existing data (as determined by the _replace_ option for _fetch()_). Receives the collection, the options, and the result (_res_). */ didFetch: function (rec, opts, res) { // the parsed result var rr = this.parse(res), s = opts.strategy, fn; if (rr) { // unfortunately we have to mark this all as having been fetched so when they // are instantiated they won't have their _isNew_ flag set to true for (var i=0, data; (data=rr[i]); ++i) { if (data) { data.isNew = false; } } // even if replace was requested it will have already taken place so we // need only evaluate the strategy for merging the new results if ((fn=this[s]) && enyo.isFunction(fn)) { fn.call(this, rr); } } if (opts) { if (opts.success) { opts.success(rec, opts, res); } } }, /** When a record fails during a request, this method is executed with the name of the command that failed, followed by a reference to the record, the original options, and the result (if any). */ didFail: function (which, rec, opts, res) { if (opts && opts.fail) { opts.fail(rec, opts, res); } }, /** Overload this method to process incoming data before _didFetch()_ attempts to merge it. This method should _always_ return an array of record hashes. */ parse: function (data) { return data; }, /** Produces an immutable hash of the contents of the collection as a JSON-parseable array. If the collection is currently filtered, it will produce only the raw output for the filtered dataset. */ raw: function () { // since we use our own _map_ method we are sure all records will be resolved return this.map(function (rec) { return rec.raw(); }); }, /** Returns the output of _raw()_ for this record as a JSON string. */ toJSON: function () { return enyo.json.stringify(this.raw()); }, /** This strategy accepts a single record (data-hash or _enyo.Model_ instance), or an array of records (data-hashes or _enyo.Model_ instances) to be merged with the current collection. This strategy may be executed directly (much like the _add()_ method) or specified as the strategy to employ with data retrieved via the _fetch()_ method. The default behavior is to find and merge records by their _primaryKey_ value when present, but _merge_ will also rely on any _mergeKeys_ set on the model kind for this collection. If the record(s) passed into this method are object-literals, they will be passed through the _parse()_ method of the model kind before being merged with existing records or being instanced as new records. Any records passed to this method that cannot be merged with existing records will be added to the collection at the end. This method will work with instanced and non-instanced records in the collection and merges without forcing records to be instanced. */ merge: function (records) { if (records) { var proto = this.model.prototype, pk = proto.primaryKey, mk = proto.mergeKeys, // the array (if any) of records to add that could not be merged add = [], // the copy of our internal records so we can remove indices already // merged and not need to iterate over them again local = this.records.slice(), // flag used during iterations to help break the loop for an incoming // record if it was successfully merged merged = false, // flag used when comparing merge keys match = false; // ensure we're working with an array of something records = (enyo.isArray(records)? records: [records]); for (var i=0, r; (r=records[i]); ++i) { // reset our flag merged = false; // if there is a value for the primary key or any merge keys were // provided we can continue var v = (r.get? r.get(pk): r[pk]); if (mk || (v !== null && v !== undefined)) { for (var j=0, c; (!merged && (c=local[j])); ++j) { // compare the primary key value if it exists if ((v !== null && v !== undefined) && v === (c.get? c.get(pk): c[pk])) { // update the flag so that the inner loop won't continue merged = true; // remove the index from the array copy so we won't check // this index again local.splice(j, 1); // otherwise we check to see if there were merge keys to check against } else if (mk) { // reset our test flag match = false; // iterate over any merge keys and compare their values if even // one doesn't match then we know the whole thing won't match // so we break the loop for (var k=0, m; (m=mk[k]); ++k) { v = (r.get? r.get(m): r[m]); if (v === (c.get? c.get(m): c[m])) { match = true; } else { match = false; break; } } // if they matched if (match) { // update the flag so that the inner loop won't continue merged = true; // remove the index from the array copy so we won't check // this index again local.splice(j, 1); } } } if (merged) { // if the current record is instanced we use the _setObject()_ method otherwise // we simply mixin the properties so it will be up to date whenever it is // instanced if (c.setObject) { c.setObject(r.raw? r.raw(): c.parse(r)); } else { enyo.mixin(c, r.raw? r.raw(): r); } } else { // if we checked the record data against all existing records and didn't merge it // we need to add it to the array that will be added at the end add.push(r); } } else { add.push(r); } } // if there were any records that needed to be added at the end of the collection // we do that now if (add.length) { this.add(add); } } }, /** Adds a passed-in record, or array of records, to the collection. Optionally, you may provide the index at which to insert the record(s). Records are added at the end by default. If additions are made successfully, an _add_ event is fired with the array of the indices of any records successfully added. The method also returns this array of indices. Records can only be added to an unfiltered dataset. If this method is called while a filter is applied, the collection will be reset prior to adding the records. */ add: function (records, i) { // since we can't add records to a filtered collection we will reset it to // unfiltered if necessary if (this.filtered) { this.reset(); } // the actual records array for the collection var local = this.records, // the array of indices of any records added to the collection add = [], // the existing length prior to adding any records len = this.length; // normalize the requested index to the appropriate starting index for // our operation i = (i !== null && !isNaN(i))? Math.max(0, Math.min(len, i)) : len; // ensure we're working with an array of incoming records/data hashes records = (enyo.isArray(records)? records: [records]); // if there aren't really any records to add we just return an empty array if (!records.length) { return add; } // we want to lazily instantiate records (unless the instanceAllRecords flag is true) for (var j=0, r; (r=records[j]); ++j) { if (!(r instanceof enyo.Model)) { // if the instanceAllRecords flag is true we have to instance it now if (this.instanceAllRecords) { records[j] = this.createRecord(r, null, false); } } else if (r.destroyed) { throw "enyo.Collection.add: cannot add a record that has already been destroyed"; } else { // adding an instantiated model so start listening for events r.addListener("change", this._recordChanged); r.addListener("destroy", this._recordDestroyed); } // add the current index + the index offset determined by the index // passed in to the method add.push(j+i); } // here we just simply use built-ins to shortcut otherwise taxing routines records.unshift.apply(records, [i, 0]); // we add these records to our own records array at the correct index local.splice.apply(local, records); // we have to return the passed-in array to its original state records.splice(0, 2); // update our new length property this.length = local.length; // if the current length is different than the original length we need to // notify any observers of this change if (len !== this.length) { this.notifyObservers("length", len, this.length); } // if necessary, trigger the `add` event for listeners if (add.length) { this.triggerEvent("add", {records: add}); } // return the array of added indices return add; }, /** Accepts a record, or an array of records, to be removed from the collection. Returns a hash of any records that were successfully removed (along with their former indices). Emits the _remove_ event, which specifies the records that were removed. Unlike the _add_ event, which contains only indices, the _remove_ event has references to the actual records. Records can only be removed from the unfiltered dataset. If this method is called while a filter is applied, the collection will be reset prior to removing the records. */ remove: function (rec) { if (this.filtered) { this.reset(); } // in order to do this as efficiently as possible we have to find any // record(s) that exist that we actually can remove and ensure that they // are ordered so, in reverse order, we can remove them without the need // to lookup their indices more than once or make copies of any arrays beyond // the ordering array, unfortunately we have to make two passes against the // records being removed // TODO: there may be even faster ways... var rr = [], d = {}, l = this.length, x, m; // if not an array, make it one rec = (enyo.isArray(rec) && rec) || [rec]; for (var j=0, r, i, k; (r=rec[j]); ++j) { if ((i=this.indexOf(r)) > -1) { if (m === undefined || i <= m) { m=i; rr.unshift(i); } else if (x === undefined || i >= x) { x=i; rr.push(i); } else if (x !== i && m !== i) { k=0; while (rr[k] < i) { ++k; } rr.splice(k, 0, i); } d[i] = r; } } // now we iterate over any indices we know we'll remove in reverse // order safely being able to use the index we just found for both the // splice and the return index for (j=rr.length-1; !isNaN((i=rr[j])); --j) { this.records.splice(i, 1); if (d[i] instanceof this.model) { d[i].removeListener("change", this._recordChanged); d[i].removeListener("destroy", this._recordDestroyed); } } // fix up our new length this.length = this.records.length; // now alert any observers of the length change if (l != this.length) { this.notifyObservers("length", l, this.length); } // trigger the event with the instances if (rr.length) { this.triggerEvent("remove", {records: d}); } return d; }, /** This method takes an array of records to replace its current records. Unlike the _add()_ method, this method emits a _reset_ event and does not emit _add_ or _remove_, even for new records. If a filter has been applied to the collection, and _reset()_ is called without a parameter, the unfiltered dataset will be restored with the exception of any removed records that existed in the filtered and original datasets; the _filtered_ flag will be reset to false. Returns a reference to the collection for chaining. */ reset: function (records) { var ch = false, l; // if the collection is filtered and this was called with no parameters if (!records && this.filtered) { var rr = this._uRecords; l = this.records.length; this._uRecords = this.records; this._uRecords = null; this.records = rr; this.length = this.records.length; this.filtered = false; ch = true; } else if (records && enyo.isArray(records)) { // if we're resetting the dataset but we're also filtering we need to // ensure we preserve the original dataset if (this.filtering) { // of course if we have already been filtered we don't reset the // original if (!this.filtered) { this._uRecords = this.records.slice(); } } l = this.records.length; this.records = records.slice(); this.length = this.records.length; ch = true; } if (ch) { if (l !== this.length) { this.notifyObservers("length", l, this.length); } this.triggerEvent("reset", {records: this.records}); } return this; }, /** If there is an _activeFilter_, this removes it and calls _reset()_ to restore the collection to an unfiltered state. Returns a reference to the collection for chaining. */ clearFilter: function () { return (this.activeFilter? this.set("activeFilter", ""): this); }, /** Removes all records from the collection. This action _does not_ destroy the records; they will simply no longer belong to this collection. If the desired action is to remove and destroy all records, use _destroyAll()_ instead. This method returns an array of all of the removed records. If _removeAll()_ is called while the collection is in a filtered state, it will reset the collection, clearing any filters, before removing all records. */ removeAll: function () { // no need to call reset prior to remove since it already checks // for the filtered state and calls reset return this.reset().remove(this.records); }, /** Removes all records from the collection and destroys them. This will still emit the _remove_ event, and any records being destroyed will also emit their own _destroy_ events. If _destroyAll()_ is called while the collection is in a filtered state, it will reset the collection, clearing any filters, before destroying all records. */ destroyAll: function () { // all of the removed records that we know need to be destroyed var records = this.removeAll(); this._destroyAll; for (var k in records) { records[k].destroy(); } this._destroyAll = false; }, /** Returns the index of the given record if it exists in this collection; otherwise, returns _-1_. Supply an optional offset to begin searching at a non-zero index. Note that when _indexOf()_ is used within an active filter, each subsequent call to _indexOf()_ will only iterate over the current filtered data unless a _reset()_ call is made to restore the entire dataset. */ indexOf: function (rec, offset) { return enyo.indexOf(rec, this.records, offset); }, /** Iterates over all the records in this collection, accepting the return value of _fn_ (under optional context _ctx_), and returning the immutable array of that result. If no context is provided, the function is executed in the context of the collection. Note that when _map()_ is used within an active filter, each subsequent call to _map()_ will only iterate over the current filtered data unless a _reset()_ call is made to restore the entire dataset. */ map: function (fn, ctx) { ctx = ctx || this; var fs = []; for (var i=0, l=this.length, r; i<l && (r=this.at(i)); ++i) { fs.push(fn.call(ctx, r, i)); } return fs; }, /** Iterates over all the records in this collection, filtering them out of the result set if _fn_ returns false. You may pass in an optional context _ctx_; otherwise, the function will be executed in the context of this collection. Returns an array of all the records that caused _fn_ to return true. Note that when _filter()_ is used within an active filter, each subsequent call to _filter()_ will only iterate over the current filtered data unless a _reset()_ call is made to restore the entire dataset. If _filter()_ is called without any parameters it will apply the _activeFilter_ to the dataset if it exists and is not already applied. It will return an immutable array of the filtered records if there was an _activeFilter_ or a copy of the entire unfiltered dataset. */ filter: function (fn, ctx) { var fs = []; if (fn) { ctx = ctx || this; for (var i=0, l=this.length, r; i<l && (r=this.at(i)); ++i) { if (fn.call(ctx, r, i)) { fs.push(r); } } } else { this._activeFilterChanged(); fs = this.records.slice(); } return fs; }, /** Returns the record at the requested index, or _undefined_ if there is none. Since records may be stored or malformed, this method resolves them as they are requested (lazily). */ at: function (i) { var r = this.records[i]; if (r && !(r instanceof this.model)) { r = this.records[i] = this.createRecord(r, null, false); } return r; }, /** Creates an instance of a record immediately in this collection. This method is used internally when instantiating records according to the _model_ property. Accepts the attributes (_attrs_) to be used, the properties (_props_) to apply, and an optional index at which to insert the record into the _collection_. If the index is false, the record will not be added to the collection at all. Returns the newly created record instance. Note that records created by a collection have their _owner_ property set to the collection and will be added to the _store_ set on the collection. If a collection is destroyed, any records it owns will also be destroyed unless the _preserveRecords_ flag is true. */ createRecord: function (attrs, props, i) { var defaults = {owner: this}, rec; // we have to check to see if we marked these attributes as being fetched // by their isNew flag and propagate that properly if so if (attrs && attrs.isNew === false) { (props || defaults).isNew = false; // remove the flag so that it doesn't show up as an attribute of the // the record delete attrs.isNew; } rec = this.store.createRecord(this.model, attrs, (props? enyo.mixin(defaults, props): defaults)); // normalize the index we're adding this record at knowing that a false // indicates we don't insert the record (because it probably already is) and // we don't update the entry here because it will be handled in the caller // if that is the case i = (false === i? -1: (i !== null && i >= 0? i: this.length)); rec.addListener("change", this._recordChanged); rec.addListener("destroy", this._recordDestroyed); if (i >= 0) { this.add(rec, i); } return rec; }, /** Implement a method called _recordChanged()_ that receives the record, the event, and any additional properties passed along when any record in the collection emits its _change_ event. */ recordChanged: null, /** When creating a new collection, you may pass it an array of records (either instances or hashes to be converted) and an optional hash of properties to be applied to the collection. Both are optional, meaning that you can supply neither, either one, or both. If both options and data are present, options will be applied first. If the _data_ array is present, it will be passed through the _parse_ method of the collection. */ constructor: enyo.inherit(function (sup) { return function (data, opts) { var d = enyo.isArray(data)? data.slice(): null, o = opts || (data && !d? data: null); if (o) { this.importProps(o); } this.records = (this.records || []).concat(d? this.parse(d): []); // initialized our length property this.length = this.records.length; // we bind this method to our collection so it can be reused as an event listener // for many records this._recordChanged = enyo.bindSafely(this, this._recordChanged); this._recordDestroyed = enyo.bindSafely(this, this._recordDestroyed); this.euid = enyo.uuid(); // attempt to resolve the kind of model if it is a string and not a constructor // for the kind var m = this.model; if (m && enyo.isString(m)) { this.model = enyo.getPath(m); } else { this.model = enyo.checkConstructor(m); } // initialize the store this.storeChanged(); this.filters = this.filters || {}; // if there are any properties designated for filtering we set those observers if (this.filterProps.length) { var fn = enyo.bindSafely(this, function () { this.triggerEvent("filter"); }); for (var j=0, ps=this.filterProps.split(" "), fp; (fp=ps[j]); ++j) { this.addObserver(fp, fn); } } this.addListener("filter", this._filterContent, this); this.addObserver("activeFilter", this._activeFilterChanged, this); data = opts = undefined; sup.apply(this, arguments); }; }), /** Destroys the collection and removes all records. This does not destroy the records unless they were created by this collection's _createRecord()_ method. To avoid destroying records that are owned by this collection, set the _preserveRecords_ flag to true. */ destroy: enyo.inherit(function (sup) { return function () { var rr = this.removeAll(), r; for (var k in rr) { r = rr[k]; if (r.owner === this) { if (this.preserveRecords) { r.owner = null; } else { r.destroy(); } } } this.triggerEvent("destroy"); this.store = null; this.removeAllListeners(); sup.apply(this, arguments); }; }), //*@protected importProps: function (p) { if (p) { if (p.records) { this.records = this.records? this.records.concat(p.records): p.records; delete p.records; } enyo.kind.statics.extend(p, this); } }, storeChanged: function () { var s = this.store || enyo.store; if (s) { if (enyo.isString(s)) { s = enyo.getPath(s); if (!s) { enyo.warn("enyo.Collection: could not find the requested store -> ", this.store, ", using" + "the default store"); } } } s = this.store = s || enyo.store; s.addCollection(this); }, _activeFilterChanged: function () { var fn = this.activeFilter, m = this.filters; // we do this so any other registered listeners will know this event // was fired instead of calling it directly if (fn && m && m[fn]) { this.triggerEvent("filter"); } else { this.reset(); } }, _filterContent: function () { if (!this.filtering && (this.length || (this._uRecords && this._uRecords.length))) { var fn = this.filters[this.activeFilter]; if (fn && this[fn]) { this.filtering = true; this.silence(); var r = this[fn](); this.unsilence(); if (r) { this.reset(true === r? undefined: r); } this.filtering = false; if (this._uRecords && this._uRecords.length) { this.filtered = true; } } } }, _recordChanged: function (rec, e, p) { // TODO: this will be used internally for relational data structures // if the developer provided a _recordChanged_ method we need to call // it now if (this.recordChanged) { this.recordChanged(rec, e, p); } }, _recordDestroyed: function (rec) { // if we're destroying all records we ignore this as the record // will have already been removed, otherwise we remove the record // from the collection if (!this._destroyAll) { this.remove(rec); } } }); enyo.Collection.concat = function (ctor, props) { var p = ctor.prototype || ctor; if (props.filters) { if (p.filters) { p.filters = enyo.mixin(enyo.clone(p.filters), props.filters); delete props.filters; } } if (props.filterProps) { // for the incoming props to a string if (enyo.isArray(props.filterProps)) { props.filterProps = props.filterProps.join(" "); } // if there isn't already one it will be assigned from the string // in the normal import props otherwise we concatenate the strings... if (p.filterProps) { p.filterProps += (" " + props.filterProps); delete props.filterProps; } } };