3 Copyright 2012 Yahoo! Inc. All rights reserved.
4 Licensed under the BSD License.
5 http://yuilibrary.com/license/
7 YUI.add('model-list', function(Y) {
10 Provides an API for managing an ordered list of Model instances.
18 Provides an API for managing an ordered list of Model instances.
20 In addition to providing convenient `add`, `create`, `reset`, and `remove`
21 methods for managing the models in the list, ModelLists are also bubble targets
22 for events on the model instances they contain. This means, for example, that
23 you can add several models to a list, and then subscribe to the `*:change` event
24 on the list to be notified whenever any model in the list changes.
26 ModelLists also maintain sort order efficiently as models are added and removed,
27 based on a custom `comparator` function you may define (if no comparator is
28 defined, models are sorted in insertion order).
37 var AttrProto = Y.Attribute.prototype,
42 Fired when a model is added to the list.
44 Listen to the `on` phase of this event to be notified before a model is
45 added to the list. Calling `e.preventDefault()` during the `on` phase will
46 prevent the model from being added.
48 Listen to the `after` phase of this event to be notified after a model has
49 been added to the list.
52 @param {Model} model The model being added.
53 @param {Number} index The index at which the model will be added.
54 @preventable _defAddFn
59 Fired when a model is created or updated via the `create()` method, but
60 before the model is actually saved or added to the list. The `add` event
61 will be fired after the model has been saved and added to the list.
64 @param {Model} model The model being created/updated.
67 EVT_CREATE = 'create',
70 Fired when an error occurs, such as when an attempt is made to add a
71 duplicate model to the list, or when a sync layer response can't be parsed.
74 @param {Any} error Error message, object, or exception generated by the
75 error. Calling `toString()` on this should result in a meaningful error
77 @param {String} src Source of the error. May be one of the following (or any
78 custom error source defined by a ModelList subclass):
80 * `add`: Error while adding a model (probably because it's already in the
81 list and can't be added again). The model in question will be provided
82 as the `model` property on the event facade.
83 * `parse`: An error parsing a JSON response. The response in question will
84 be provided as the `response` property on the event facade.
85 * `remove`: Error while removing a model (probably because it isn't in the
86 list and can't be removed). The model in question will be provided as
87 the `model` property on the event facade.
92 Fired after models are loaded from a sync layer.
95 @param {Object} parsed The parsed version of the sync layer's response to
97 @param {Mixed} response The sync layer's raw, unparsed response to the load
104 Fired when a model is removed from the list.
106 Listen to the `on` phase of this event to be notified before a model is
107 removed from the list. Calling `e.preventDefault()` during the `on` phase
108 will prevent the model from being removed.
110 Listen to the `after` phase of this event to be notified after a model has
111 been removed from the list.
114 @param {Model} model The model being removed.
115 @param {Number} index The index of the model being removed.
116 @preventable _defRemoveFn
118 EVT_REMOVE = 'remove',
121 Fired when the list is completely reset via the `reset()` method or sorted
122 via the `sort()` method.
124 Listen to the `on` phase of this event to be notified before the list is
125 reset. Calling `e.preventDefault()` during the `on` phase will prevent
126 the list from being reset.
128 Listen to the `after` phase of this event to be notified after the list has
132 @param {Model[]} models Array of the list's new models after the reset.
133 @param {String} src Source of the event. May be either `'reset'` or
135 @preventable _defResetFn
139 function ModelList() {
140 ModelList.superclass.constructor.apply(this, arguments);
143 Y.ModelList = Y.extend(ModelList, Y.Base, {
144 // -- Public Properties ----------------------------------------------------
147 The `Model` class or subclass of the models in this list.
149 The class specified here will be used to create model instances
150 automatically based on attribute hashes passed to the `add()`, `create()`,
151 and `reset()` methods.
153 You may specify the class as an actual class reference or as a string that
154 resolves to a class reference at runtime (the latter can be useful if the
155 specified class will be loaded lazily).
163 // -- Protected Properties -------------------------------------------------
166 Total hack to allow us to identify ModelList instances without using
167 `instanceof`, which won't work when the instance was created in another
168 window or YUI sandbox.
170 @property _isYUIModelList
176 _isYUIModelList: true,
178 // -- Lifecycle Methods ----------------------------------------------------
179 initializer: function (config) {
180 config || (config = {});
182 var model = this.model = config.model || this.model;
184 if (typeof model === 'string') {
185 // Look for a namespaced Model class on `Y`.
186 this.model = Y.Object.getValue(Y, model.split('.'));
189 Y.error('ModelList: Model class not found: ' + model);
193 this.publish(EVT_ADD, {defaultFn: this._defAddFn});
194 this.publish(EVT_RESET, {defaultFn: this._defResetFn});
195 this.publish(EVT_REMOVE, {defaultFn: this._defRemoveFn});
197 this.after('*:idChange', this._afterIdChange);
202 destructor: function () {
203 YArray.each(this._items, this._detachList, this);
206 // -- Public Methods -------------------------------------------------------
209 Adds the specified model or array of models to this list. You may also pass
210 another ModelList instance, in which case all the models in that list will
211 be added to this one as well.
215 // Add a single model instance.
216 list.add(new Model({foo: 'bar'}));
218 // Add a single model, creating a new instance automatically.
219 list.add({foo: 'bar'});
221 // Add multiple models, creating new instances automatically.
227 // Add all the models in another ModelList instance.
231 @param {Model|Model[]|ModelList|Object|Object[]} models Model or array of
232 models to add. May be existing model instances or hashes of model
233 attributes, in which case new model instances will be created from the
234 hashes. You may also pass a ModelList instance to add all the models it
236 @param {Object} [options] Data to be mixed into the event facade of the
237 `add` event(s) for the added models.
239 @param {Boolean} [options.silent=false] If `true`, no `add` event(s)
242 @return {Model|Model[]} Added model or array of added models.
244 add: function (models, options) {
245 var isList = models._isYUIModelList;
247 if (isList || Lang.isArray(models)) {
248 return YArray.map(isList ? models.toArray() : models, function (model) {
249 return this._add(model, options);
252 return this._add(models, options);
257 Define this method to provide a function that takes a model as a parameter
258 and returns a value by which that model should be sorted relative to other
261 By default, no comparator is defined, meaning that models will not be sorted
262 (they'll be stored in the order they're added).
265 var list = new Y.ModelList({model: Y.Model});
267 list.comparator = function (model) {
268 return model.get('id'); // Sort models by id.
272 @param {Model} model Model being sorted.
273 @return {Number|String} Value by which the model should be sorted relative
274 to other models in this list.
277 // comparator is not defined by default
280 Creates or updates the specified model on the server, then adds it to this
281 list if the server indicates success.
284 @param {Model|Object} model Model to create. May be an existing model
285 instance or a hash of model attributes, in which case a new model instance
286 will be created from the hash.
287 @param {Object} [options] Options to be passed to the model's `sync()` and
288 `set()` methods and mixed into the `create` and `add` event facades.
289 @param {Boolean} [options.silent=false] If `true`, no `add` event(s) will
291 @param {Function} [callback] Called when the sync operation finishes.
292 @param {Error} callback.err If an error occurred, this parameter will
293 contain the error. If the sync operation succeeded, _err_ will be
295 @param {Any} callback.response The server's response.
296 @return {Model} Created model.
298 create: function (model, options, callback) {
301 // Allow callback as second arg.
302 if (typeof options === 'function') {
307 options || (options = {});
309 if (!model._isYUIModel) {
310 model = new this.model(model);
313 self.fire(EVT_CREATE, Y.merge(options, {
317 return model.save(options, function (err) {
319 self.add(model, options);
322 callback && callback.apply(null, arguments);
327 Executes the supplied function on each model in this list. Returns an array
328 containing the models for which the supplied function returned a truthy
331 The callback function's `this` object will refer to this ModelList. Use
332 `Y.bind()` to bind the `this` object to another object if desired.
336 // Get an array containing only the models whose "enabled" attribute is
338 var filtered = list.filter(function (model) {
339 return model.get('enabled');
342 // Get a new ModelList containing only the models whose "enabled"
343 // attribute is truthy.
344 var filteredList = list.filter({asList: true}, function (model) {
345 return model.get('enabled');
349 @param {Object} [options] Filter options.
350 @param {Boolean} [options.asList=false] If truthy, results will be
351 returned as a new ModelList instance rather than as an array.
353 @param {Function} callback Function to execute on each model.
354 @param {Model} callback.model Model instance.
355 @param {Number} callback.index Index of the current model.
356 @param {ModelList} callback.list The ModelList being filtered.
358 @return {Array|ModelList} Array of models for which the callback function
359 returned a truthy value (empty if it never returned a truthy value). If
360 the `options.asList` option is truthy, a new ModelList instance will be
361 returned instead of an array.
364 filter: function (options, callback) {
369 // Allow options as first arg.
370 if (typeof options === 'function') {
375 for (i = 0, len = items.length; i < len; ++i) {
378 if (callback.call(this, item, i, this)) {
383 if (options.asList) {
384 list = new Y.ModelList({model: this.model});
385 filtered.length && list.add(filtered, {silent: true});
393 If _name_ refers to an attribute on this ModelList instance, returns the
394 value of that attribute. Otherwise, returns an array containing the values
395 of the specified attribute from each model in this list.
398 @param {String} name Attribute name or object property path.
399 @return {Any|Array} Attribute value or array of attribute values.
402 get: function (name) {
403 if (this.attrAdded(name)) {
404 return AttrProto.get.apply(this, arguments);
407 return this.invoke('get', name);
411 If _name_ refers to an attribute on this ModelList instance, returns the
412 HTML-escaped value of that attribute. Otherwise, returns an array containing
413 the HTML-escaped values of the specified attribute from each model in this
416 The values are escaped using `Escape.html()`.
419 @param {String} name Attribute name or object property path.
420 @return {String|String[]} HTML-escaped value or array of HTML-escaped
422 @see Model.getAsHTML()
424 getAsHTML: function (name) {
425 if (this.attrAdded(name)) {
426 return Y.Escape.html(AttrProto.get.apply(this, arguments));
429 return this.invoke('getAsHTML', name);
433 If _name_ refers to an attribute on this ModelList instance, returns the
434 URL-encoded value of that attribute. Otherwise, returns an array containing
435 the URL-encoded values of the specified attribute from each model in this
438 The values are encoded using the native `encodeURIComponent()` function.
441 @param {String} name Attribute name or object property path.
442 @return {String|String[]} URL-encoded value or array of URL-encoded values.
443 @see Model.getAsURL()
445 getAsURL: function (name) {
446 if (this.attrAdded(name)) {
447 return encodeURIComponent(AttrProto.get.apply(this, arguments));
450 return this.invoke('getAsURL', name);
454 Returns the model with the specified _clientId_, or `null` if not found.
456 @method getByClientId
457 @param {String} clientId Client id.
458 @return {Model} Model, or `null` if not found.
460 getByClientId: function (clientId) {
461 return this._clientIdMap[clientId] || null;
465 Returns the model with the specified _id_, or `null` if not found.
467 Note that models aren't expected to have an id until they're saved, so if
468 you're working with unsaved models, it may be safer to call
472 @param {String|Number} id Model id.
473 @return {Model} Model, or `null` if not found.
475 getById: function (id) {
476 return this._idMap[id] || null;
480 Calls the named method on every model in the list. Any arguments provided
481 after _name_ will be passed on to the invoked method.
484 @param {String} name Name of the method to call on each model.
485 @param {Any} [args*] Zero or more arguments to pass to the invoked method.
486 @return {Array} Array of return values, indexed according to the index of
487 the model on which the method was called.
489 invoke: function (name /*, args* */) {
490 var args = [this._items, name].concat(YArray(arguments, 1, true));
491 return YArray.invoke.apply(YArray, args);
495 Returns the model at the specified _index_.
498 @param {Number} index Index of the model to fetch.
499 @return {Model} The model at the specified index, or `undefined` if there
503 // item() is inherited from ArrayList.
506 Loads this list of models from the server.
508 This method delegates to the `sync()` method to perform the actual load
509 operation, which is an asynchronous action. Specify a _callback_ function to
510 be notified of success or failure.
512 If the load operation succeeds, a `reset` event will be fired.
515 @param {Object} [options] Options to be passed to `sync()` and to
516 `reset()` when adding the loaded models. It's up to the custom sync
517 implementation to determine what options it supports or requires, if any.
518 @param {Function} [callback] Called when the sync operation finishes.
519 @param {Error} callback.err If an error occurred, this parameter will
520 contain the error. If the sync operation succeeded, _err_ will be
522 @param {Any} callback.response The server's response. This value will
523 be passed to the `parse()` method, which is expected to parse it and
524 return an array of model attribute hashes.
527 load: function (options, callback) {
530 // Allow callback as only arg.
531 if (typeof options === 'function') {
536 options || (options = {});
538 this.sync('read', options, function (err, response) {
550 self.fire(EVT_ERROR, facade);
553 if (!self._loadEvent) {
554 self._loadEvent = self.publish(EVT_LOAD, {
559 parsed = facade.parsed = self.parse(response);
561 self.reset(parsed, options);
562 self.fire(EVT_LOAD, facade);
565 callback && callback.apply(null, arguments);
572 Executes the specified function on each model in this list and returns an
573 array of the function's collected return values.
576 @param {Function} fn Function to execute on each model.
577 @param {Model} fn.model Current model being iterated.
578 @param {Number} fn.index Index of the current model in the list.
579 @param {Model[]} fn.models Array of models being iterated.
580 @param {Object} [thisObj] `this` object to use when calling _fn_.
581 @return {Array} Array of return values from _fn_.
583 map: function (fn, thisObj) {
584 return YArray.map(this._items, fn, thisObj);
588 Called to parse the _response_ when the list is loaded from the server.
589 This method receives a server _response_ and is expected to return an array
590 of model attribute hashes.
592 The default implementation assumes that _response_ is either an array of
593 attribute hashes or a JSON string that can be parsed into an array of
594 attribute hashes. If _response_ is a JSON string and either `Y.JSON` or the
595 native `JSON` object are available, it will be parsed automatically. If a
596 parse error occurs, an `error` event will be fired and the model will not be
599 You may override this method to implement custom parsing logic if necessary.
602 @param {Any} response Server response.
603 @return {Object[]} Array of model attribute hashes.
605 parse: function (response) {
606 if (typeof response === 'string') {
608 return Y.JSON.parse(response) || [];
610 this.fire(EVT_ERROR, {
620 return response || [];
624 Removes the specified model or array of models from this list. You may also
625 pass another ModelList instance to remove all the models that are in both
626 that instance and this instance.
629 @param {Model|Model[]|ModelList} models Models to remove.
630 @param {Object} [options] Data to be mixed into the event facade of the
631 `remove` event(s) for the removed models.
633 @param {Boolean} [options.silent=false] If `true`, no `remove` event(s)
636 @return {Model|Model[]} Removed model or array of removed models.
638 remove: function (models, options) {
639 var isList = models._isYUIModelList;
641 if (isList || Lang.isArray(models)) {
642 return YArray.map(isList ? models.toArray() : models, function (model) {
643 return this._remove(model, options);
646 return this._remove(models, options);
651 Completely replaces all models in the list with those specified, and fires a
652 single `reset` event.
654 Use `reset` when you want to add or remove a large number of items at once
655 with less overhead, and without firing `add` or `remove` events for each
659 @param {Model[]|ModelList|Object[]} [models] Models to add. May be existing
660 model instances or hashes of model attributes, in which case new model
661 instances will be created from the hashes. If a ModelList is passed, all
662 the models in that list will be added to this list. Calling `reset()`
663 without passing in any models will clear the list.
664 @param {Object} [options] Data to be mixed into the event facade of the
667 @param {Boolean} [options.silent=false] If `true`, no `reset` event will
672 reset: function (models, options) {
673 models || (models = []);
674 options || (options = {});
676 var facade = Y.merge({src: 'reset'}, options);
678 if (models._isYUIModelList) {
679 models = models.toArray();
681 models = YArray.map(models, function (model) {
682 return model._isYUIModel ? model : new this.model(model);
686 facade.models = models;
688 if (options.silent) {
689 this._defResetFn(facade);
691 // Sort the models before firing the reset event.
692 if (this.comparator) {
693 models.sort(Y.bind(this._sort, this));
696 this.fire(EVT_RESET, facade);
703 Forcibly re-sorts the list.
705 Usually it shouldn't be necessary to call this method since the list
706 maintains its sort order when items are added and removed, but if you change
707 the `comparator` function after items are already in the list, you'll need
711 @param {Object} [options] Data to be mixed into the event facade of the
713 @param {Boolean} [options.silent=false] If `true`, no `reset` event will
717 sort: function (options) {
718 if (!this.comparator) {
722 var models = this._items.concat(),
725 options || (options = {});
727 models.sort(Y.bind(this._sort, this));
729 facade = Y.merge(options, {
734 options.silent ? this._defResetFn(facade) :
735 this.fire(EVT_RESET, facade);
741 Override this method to provide a custom persistence implementation for this
742 list. The default method just calls the callback without actually doing
745 This method is called internally by `load()`.
748 @param {String} action Sync action to perform. May be one of the following:
750 * `create`: Store a list of newly-created models for the first time.
751 * `delete`: Delete a list of existing models.
752 * `read` : Load a list of existing models.
753 * `update`: Update a list of existing models.
755 Currently, model lists only make use of the `read` action, but other
756 actions may be used in future versions.
758 @param {Object} [options] Sync options. It's up to the custom sync
759 implementation to determine what options it supports or requires, if any.
760 @param {Function} [callback] Called when the sync operation finishes.
761 @param {Error} callback.err If an error occurred, this parameter will
762 contain the error. If the sync operation succeeded, _err_ will be
764 @param {Any} [callback.response] The server's response. This value will
765 be passed to the `parse()` method, which is expected to parse it and
766 return an array of model attribute hashes.
768 sync: function (/* action, options, callback */) {
769 var callback = YArray(arguments, 0, true).pop();
771 if (typeof callback === 'function') {
777 Returns an array containing the models in this list.
780 @return {Array} Array containing the models in this list.
782 toArray: function () {
783 return this._items.concat();
787 Returns an array containing attribute hashes for each model in this list,
788 suitable for being passed to `Y.JSON.stringify()`.
790 Under the hood, this method calls `toJSON()` on each model in the list and
791 pushes the results into an array.
794 @return {Object[]} Array of model attribute hashes.
797 toJSON: function () {
798 return this.map(function (model) {
799 return model.toJSON();
803 // -- Protected Methods ----------------------------------------------------
806 Adds the specified _model_ if it isn't already in this list.
808 If the model's `clientId` or `id` matches that of a model that's already in
809 the list, an `error` event will be fired and the model will not be added.
812 @param {Model|Object} model Model or object to add.
813 @param {Object} [options] Data to be mixed into the event facade of the
814 `add` event for the added model.
815 @param {Boolean} [options.silent=false] If `true`, no `add` event will be
817 @return {Model} The added model.
820 _add: function (model, options) {
823 options || (options = {});
825 if (!model._isYUIModel) {
826 model = new this.model(model);
829 id = model.get('id');
831 if (this._clientIdMap[model.get('clientId')]
832 || (Lang.isValue(id) && this._idMap[id])) {
834 this.fire(EVT_ERROR, {
835 error: 'Model is already in the list.',
843 facade = Y.merge(options, {
844 index: this._findIndex(model),
848 options.silent ? this._defAddFn(facade) : this.fire(EVT_ADD, facade);
854 Adds this list as a bubble target for the specified model's events.
857 @param {Model} model Model to attach to this list.
860 _attachList: function (model) {
861 // Attach this list and make it a bubble target for the model.
862 model.lists.push(this);
863 model.addTarget(this);
867 Clears all internal state and the internal list of models, returning this
868 list to an empty state. Automatically detaches all models in the list.
873 _clear: function () {
874 YArray.each(this._items, this._detachList, this);
876 this._clientIdMap = {};
882 Compares the value _a_ to the value _b_ for sorting purposes. Values are
883 assumed to be the result of calling a model's `comparator()` method. You can
884 override this method to implement custom sorting logic, such as a descending
885 sort or multi-field sorting.
888 @param {Mixed} a First value to compare.
889 @param {Mixed} b Second value to compare.
890 @return {Number} `-1` if _a_ should come before _b_, `0` if they're
891 equivalent, `1` if _a_ should come after _b_.
895 _compare: function (a, b) {
896 return a < b ? -1 : (a > b ? 1 : 0);
900 Removes this list as a bubble target for the specified model's events.
903 @param {Model} model Model to detach.
906 _detachList: function (model) {
907 var index = YArray.indexOf(model.lists, this);
910 model.lists.splice(index, 1);
911 model.removeTarget(this);
916 Returns the index at which the given _model_ should be inserted to maintain
917 the sort order of the list.
920 @param {Model} model The model being inserted.
921 @return {Number} Index at which the model should be inserted.
924 _findIndex: function (model) {
925 var items = this._items,
928 item, middle, needle;
930 if (!this.comparator || !max) {
934 needle = this.comparator(model);
936 // Perform an iterative binary search to determine the correct position
937 // based on the return value of the `comparator` function.
939 middle = (min + max) >> 1; // Divide by two and discard remainder.
940 item = items[middle];
942 if (this._compare(this.comparator(item), needle) < 0) {
953 Removes the specified _model_ if it's in this list.
956 @param {Model} model Model to remove.
957 @param {Object} [options] Data to be mixed into the event facade of the
958 `remove` event for the removed model.
959 @param {Boolean} [options.silent=false] If `true`, no `remove` event will
961 @return {Model} Removed model.
964 _remove: function (model, options) {
965 var index = this.indexOf(model),
968 options || (options = {});
971 this.fire(EVT_ERROR, {
972 error: 'Model is not in the list.',
980 facade = Y.merge(options, {
985 options.silent ? this._defRemoveFn(facade) :
986 this.fire(EVT_REMOVE, facade);
992 Array sort function used by `sort()` to re-sort the models in the list.
995 @param {Model} a First model to compare.
996 @param {Model} b Second model to compare.
997 @return {Number} `-1` if _a_ is less than _b_, `0` if equal, `1` if greater.
1000 _sort: function (a, b) {
1001 return this._compare(this.comparator(a), this.comparator(b));
1004 // -- Event Handlers -------------------------------------------------------
1007 Updates the model maps when a model's `id` attribute changes.
1009 @method _afterIdChange
1010 @param {EventFacade} e
1013 _afterIdChange: function (e) {
1014 Lang.isValue(e.prevVal) && delete this._idMap[e.prevVal];
1015 Lang.isValue(e.newVal) && (this._idMap[e.newVal] = e.target);
1018 // -- Default Event Handlers -----------------------------------------------
1021 Default event handler for `add` events.
1024 @param {EventFacade} e
1027 _defAddFn: function (e) {
1028 var model = e.model,
1029 id = model.get('id');
1031 this._clientIdMap[model.get('clientId')] = model;
1033 if (Lang.isValue(id)) {
1034 this._idMap[id] = model;
1037 this._attachList(model);
1038 this._items.splice(e.index, 0, model);
1042 Default event handler for `remove` events.
1044 @method _defRemoveFn
1045 @param {EventFacade} e
1048 _defRemoveFn: function (e) {
1049 var model = e.model,
1050 id = model.get('id');
1052 this._detachList(model);
1053 delete this._clientIdMap[model.get('clientId')];
1055 if (Lang.isValue(id)) {
1056 delete this._idMap[id];
1059 this._items.splice(e.index, 1);
1063 Default event handler for `reset` events.
1066 @param {EventFacade} e
1069 _defResetFn: function (e) {
1070 // When fired from the `sort` method, we don't need to clear the list or
1071 // add any models, since the existing models are sorted in place.
1072 if (e.src === 'sort') {
1073 this._items = e.models.concat();
1079 if (e.models.length) {
1080 this.add(e.models, {silent: true});
1087 Y.augment(ModelList, Y.ArrayList);
1090 }, '3.5.1' ,{requires:['array-extras', 'array-invoke', 'arraylist', 'base-build', 'escape', 'json-parse', 'model']});