1 // ==========================================================================
  2 // Project:   SproutCore - JavaScript Application Framework
  3 // Copyright: ©2006-2011 Strobe Inc. and contributors.
  4 //            Portions ©2008-2011 Apple Inc. All rights reserved.
  5 // License:   Licensed under MIT license (see license.js)
  6 // ==========================================================================
  7 
  8 sc_require('controllers/controller');
  9 sc_require('mixins/selection_support');
 10 
 11 /**
 12   @class
 13 
 14   An ArrayController provides a way for you to publish an array of objects
 15   for CollectionView or other controllers to work with.  To work with an
 16   ArrayController, set the content property to the array you want the
 17   controller to manage.  Then work directly with the controller object as if
 18   it were the array itself.
 19 
 20   When you want to display an array of objects in a CollectionView, bind the
 21   "arrangedObjects" of the array controller to the CollectionView's "content"
 22   property.  This will automatically display the array in the collection view.
 23 
 24   @extends SC.Controller
 25   @extends SC.Array
 26   @extends SC.SelectionSupport
 27   @author Charles Jolley
 28   @since SproutCore 1.0
 29 */
 30 SC.ArrayController = SC.Controller.extend(SC.Array, SC.SelectionSupport,
 31 /** @scope SC.ArrayController.prototype */ {
 32 
 33   //@if(debug)
 34   /* BEGIN DEBUG ONLY PROPERTIES AND METHODS */
 35 
 36   /* @private */
 37   toString: function () {
 38     var content = this.get('content'),
 39       ret = sc_super();
 40 
 41     return content ? "%@:\n  ↳ %@".fmt(ret, content) : ret;
 42   },
 43 
 44   /* END DEBUG ONLY PROPERTIES AND METHODS */
 45   //@endif
 46 
 47   // ..........................................................
 48   // PROPERTIES
 49   //
 50 
 51   /**
 52     The content array managed by this controller.
 53 
 54     You can set the content of the ArrayController to any object that
 55     implements SC.Array or SC.Enumerable.  If you set the content to an object
 56     that implements SC.Enumerable only, you must also set the orderBy property
 57     so that the ArrayController can order the enumerable for you.
 58 
 59     If you set the content to a non-enumerable and non-array object, then the
 60     ArrayController will wrap the item in an array in an attempt to normalize
 61     the result.
 62 
 63     @type SC.Array
 64   */
 65   content: null,
 66 
 67   /**
 68     Makes the array editable or not.  If this is set to NO, then any attempts
 69     at changing the array content itself will throw an exception.
 70 
 71     @type Boolean
 72   */
 73   isEditable: YES,
 74 
 75   /**
 76     Used to sort the array.
 77 
 78     If you set this property to a key name, array of key names, or a function,
 79     then then ArrayController will automatically reorder your content array
 80     to match the sort order.  When using key names, you may specify the
 81     direction of the sort by appending ASC or DESC to the key name.  By default
 82     sorting is done in ascending order.
 83 
 84     For example,
 85 
 86         myController.set('orderBy', 'title DESC');
 87         myController.set('orderBy', ['lastName ASC', 'firstName DESC']);
 88 
 89     Normally, you should only use this property if you set the content of the
 90     controller to an unordered enumerable such as SC.Set or SC.SelectionSet.
 91     In this case the orderBy property is required in order for the controller
 92     to property order the content for display.
 93 
 94     If you set the content to an array, it is usually best to maintain the
 95     array in the proper order that you want to display things rather than
 96     using this method to order the array since it requires an extra processing
 97     step.  You can use this orderBy property, however, for displaying smaller
 98     arrays of content.
 99 
100     Note that you can only use addObject() to insert new objects into an
101     array that is ordered.  You cannot manually reorder or insert new objects
102     into specific locations because the order is managed by this property
103     instead.
104 
105     If you pass a function, it should be suitable for use in compare().
106 
107     @type String|Array|Function
108   */
109   orderBy: null,
110 
111   /**
112     Set to YES if you want the controller to wrap non-enumerable content
113     in an array and publish it.  Otherwise, it will treat single content like
114     null content.
115 
116     @type Boolean
117   */
118   allowsSingleContent: YES,
119 
120   /**
121     Set to YES if you want objects removed from the array to also be
122     deleted.  This is a convenient way to manage lists of items owned
123     by a parent record object.
124 
125     Note that even if this is set to NO, calling destroyObject() instead of
126     removeObject() will still destroy the object in question as well as
127     removing it from the parent array.
128 
129     @type Boolean
130   */
131   destroyOnRemoval: NO,
132 
133   /**
134     Returns an SC.Array object suitable for use in a CollectionView.
135     Depending on how you have your ArrayController configured, this property
136     may be one of several different values.
137 
138     @type SC.Array
139   */
140   arrangedObjects: function () {
141     return this;
142   }.property().cacheable(),
143 
144   /**
145     Computed property indicates whether or not the array controller can
146     remove content.  You can delete content only if the content is not single
147     content and isEditable is YES.
148 
149     @type Boolean
150   */
151   canRemoveContent: function () {
152     var content = this.get('content'), ret;
153     ret = !!content && this.get('isEditable') && this.get('hasContent');
154     if (ret) {
155       return !content.isEnumerable ||
156              (SC.typeOf(content.removeObject) === SC.T_FUNCTION);
157     } else return NO;
158   }.property('content', 'isEditable', 'hasContent'),
159 
160   /**
161     Computed property indicates whether you can reorder content.  You can
162     reorder content as long a the controller isEditable and the content is a
163     real SC.Array-like object.  You cannot reorder content when orderBy is
164     non-null.
165 
166     @type Boolean
167   */
168   canReorderContent: function () {
169     var content = this.get('content'), ret;
170     ret = !!content && this.get('isEditable') && !this.get('orderBy');
171     return ret && !!content.isSCArray;
172   }.property('content', 'isEditable', 'orderBy'),
173 
174   /**
175     Computed property insides whether you can add content.  You can add
176     content as long as the controller isEditable and the content is not a
177     single object.
178 
179     Note that the only way to simply add object to an ArrayController is to
180     use the addObject() or pushObject() methods.  All other methods imply
181     reordering and will fail.
182 
183     @type Boolean
184   */
185   canAddContent: function () {
186     var content = this.get('content'), ret;
187     ret = content && this.get('isEditable') && content.isEnumerable;
188     if (ret) {
189       return (SC.typeOf(content.addObject) === SC.T_FUNCTION) ||
190              (SC.typeOf(content.pushObject) === SC.T_FUNCTION);
191     } else return NO;
192   }.property('content', 'isEditable'),
193 
194   /**
195     Set to YES if the controller has valid content that can be displayed,
196     even an empty array.  Returns NO if the content is null or not enumerable
197     and allowsSingleContent is NO.
198 
199     @type Boolean
200   */
201   hasContent: function () {
202     var content = this.get('content');
203     return !!content &&
204            (!!content.isEnumerable || !!this.get('allowsSingleContent'));
205   }.property('content', 'allowSingleContent'),
206 
207   /**
208     Returns the current status property for the content.  If the content does
209     not have a status property, returns SC.Record.READY.
210 
211     @type Number
212   */
213   status: function () {
214     var content = this.get('content'),
215         ret = content ? content.get('status') : null;
216     return ret ? ret : SC.Record.READY;
217   }.property().cacheable(),
218 
219   // ..........................................................
220   // METHODS
221   //
222 
223   /**
224     Adds an object to the array.  If the content is ordered, this will add the
225     object to the end of the content array.  The content is not ordered, the
226     location depends on the implementation of the content.
227 
228     If the source content does not support adding an object, then this method
229     will throw an exception.
230 
231     @param {Object} object The object to add to the array.
232     @returns {SC.ArrayController} The receiver.
233   */
234   addObject: function (object) {
235     if (!this.get('canAddContent')) { throw new Error("%@ cannot add content".fmt(this)); }
236 
237     var content = this.get('content');
238     if (content.isSCArray) { content.pushObject(object); }
239     else if (content.addObject) { content.addObject(object); }
240     else { throw new Error("%@.content does not support addObject".fmt(this)); }
241 
242     return this;
243   },
244 
245   /**
246     Removes the passed object from the array.  If the underlying content
247     is a single object, then this simply sets the content to null.  Otherwise
248     it will call removeObject() on the content.
249 
250     Also, if destroyOnRemoval is YES, this will actually destroy the object.
251 
252     @param {Object} object the object to remove
253     @returns {SC.ArrayController} receiver
254   */
255   removeObject: function (object) {
256     if (!this.get('canRemoveContent')) {
257       throw new Error("%@ cannot remove content".fmt(this));
258     }
259 
260     var content = this.get('content');
261     if (content.isEnumerable) {
262       content.removeObject(object);
263     } else {
264       this.set('content', null);
265     }
266 
267     if (this.get('destroyOnRemoval') && object.destroy) { object.destroy(); }
268     return this;
269   },
270 
271   // ..........................................................
272   // SC.ARRAY SUPPORT
273   //
274 
275   /**
276     Compute the length of the array based on the observable content
277 
278     @type Number
279   */
280   length: function () {
281     var content = this._scac_observableContent();
282     return content ? content.get('length') : 0;
283   }.property().cacheable(),
284 
285   /** @private
286     Returns the object at the specified index based on the observable content
287   */
288   objectAt: function (idx) {
289     var content = this._scac_observableContent();
290     return content ? content.objectAt(idx) : undefined;
291   },
292 
293   /** @private
294     Forwards a replace on to the content, but only if reordering is allowed.
295   */
296   replace: function (start, amt, objects) {
297     // check for various conditions before a replace is allowed
298     if (!objects || objects.get('length') === 0) {
299       if (!this.get('canRemoveContent')) {
300         throw new Error("%@ cannot remove objects from the current content".fmt(this));
301       }
302     } else if (!this.get('canReorderContent')) {
303       throw new Error("%@ cannot add or reorder the current content".fmt(this));
304     }
305 
306     // if we can do this, then just forward the change.  This should fire
307     // updates back up the stack, updating rangeObservers, etc.
308     var content = this.get('content'); // note: use content, not observable
309     var objsToDestroy = [], i, objsLen;
310 
311     if (this.get('destroyOnRemoval')) {
312       for (i = 0; i < amt; i++) {
313         objsToDestroy.push(content.objectAt(i + start));
314       }
315     }
316 
317     if (content) { content.replace(start, amt, objects); }
318 
319     for (i = 0, objsLen = objsToDestroy.length; i < objsLen; i++) {
320 
321       objsToDestroy[i].destroy();
322     }
323     objsToDestroy = null;
324 
325     return this;
326   },
327 
328   indexOf: function (object, startAt) {
329     var content = this._scac_observableContent();
330     return content ? content.indexOf(object, startAt) : -1;
331   },
332 
333   // ..........................................................
334   // INTERNAL SUPPORT
335   //
336 
337   /** @private */
338   init: function () {
339     sc_super();
340     this._scac_contentDidChange();
341   },
342 
343   /** @private
344     Cached observable content property.  Set to NO to indicate cache is
345     invalid.
346   */
347   _scac_cached: NO,
348 
349   /**
350     @private
351 
352     Returns the current array this controller is actually managing.  Usually
353     this should be the same as the content property, but sometimes we need to
354     generate something different because the content is not a regular array.
355 
356     @returns {SC.Array} observable or null
357   */
358   _scac_observableContent: function () {
359     var ret = this._scac_cached;
360     if (ret) { return ret; }
361 
362     var content = this.get('content'), func, order;
363 
364     // empty content
365     if (SC.none(content)) { return (this._scac_cached = []); }
366 
367     // wrap non-enumerables
368     if (!content.isEnumerable) {
369       ret = this.get('allowsSingleContent') ? [content] : [];
370       return (this._scac_cached = ret);
371     }
372 
373     // no-wrap
374     var orderBy = this.get('orderBy');
375     if (!orderBy) {
376       if (content.isSCArray) { return (this._scac_cached = content); }
377       else { throw new Error("%@.orderBy is required for unordered content".fmt(this)); }
378     }
379 
380     // all remaining enumerables must be sorted.
381 
382     // build array - then sort it
383     var type = SC.typeOf(orderBy);
384 
385     if (type === SC.T_STRING) {
386       orderBy = [orderBy];
387     } else if (type === SC.T_FUNCTION) {
388       func = orderBy;
389     } else if (type !== SC.T_ARRAY) {
390       throw new Error("%@.orderBy must be Array, String, or Function".fmt(this));
391     }
392 
393     // generate comparison function if needed - use orderBy
394     func = func || function (a, b) {
395       var status, key, match, valueA, valueB;
396 
397       for (var i = 0, l = orderBy.get('length'); i < l && !status; i++) {
398         key = orderBy.objectAt(i);
399 
400         if (key.search(/(ASC|DESC)/) === 0) {
401           //@if(debug)
402           SC.warn("Developer Warning: SC.ArrayController's orderBy direction syntax has been changed to match that of SC.Query and MySQL.  Please change your String to 'key DESC' or 'key ASC'.  Having 'ASC' or 'DESC' precede the key has been deprecated.");
403           //@endif
404           match = key.match(/^(ASC )?(DESC )?(.*)$/);
405           key = match[3];
406         } else {
407           match = key.match(/^(\S*)\s*(DESC)?(?:ASC)?$/);
408           key = match[1];
409         }
410         order = match[2] ? -1 : 1;
411 
412         if (a) { valueA = a.isObservable ? a.get(key) : a[key]; }
413         if (b) { valueB = b.isObservable ? b.get(key) : b[key]; }
414 
415         status = SC.compare(valueA, valueB) * order;
416       }
417 
418       return status;
419     };
420 
421     return (this._scac_cached = content.toArray().sort(func));
422   },
423 
424   propertyWillChange: function (key) {
425     if (key === 'content') {
426       this.arrayContentWillChange(0, this.get('length'), 0);
427     } else {
428       return sc_super();
429     }
430   },
431 
432   _scac_arrayContentWillChange: function (start, removed, added) {
433     // Repoint arguments if orderBy is present. (If orderBy is present, we can't be sure how any content change
434     // translates into an arrangedObject change without calculating the order, which is a complex, potentially
435     // expensive operation, so we simply invalidate everything.)
436     if (this.get('orderBy')) {
437       var len = this.get('length');
438       start = 0;
439       added = len + added - removed;
440       removed = len;
441     }
442 
443     // Continue.
444     this.arrayContentWillChange(start, removed, added);
445     if (this._kvo_enumerable_property_chains) {
446       var removedObjects = this.slice(start, start + removed);
447       this.teardownEnumerablePropertyChains(removedObjects);
448     }
449   },
450 
451   _scac_arrayContentDidChange: function (start, removed, added) {
452     this._scac_cached = NO;
453 
454     // Repoint arguments if orderBy is present. (If orderBy is present, we can't be sure how any content change
455     // translates into an arrangedObject change without calculating the order, which is a complex, potentially
456     // expensive operation, so we simply invalidate everything.)
457     if (this.get('orderBy')) {
458       var len = this.get('length');
459       start = 0;
460       added = len + added - removed;
461       removed = len;
462     }
463 
464     // Notify range, firstObject, lastObject and '[]' observers.
465     this.arrayContentDidChange(start, removed, added);
466 
467     if (this._kvo_enumerable_property_chains) {
468       var addedObjects = this.slice(start, start + added);
469       this.setupEnumerablePropertyChains(addedObjects);
470     }
471     this.updateSelectionAfterContentChange();
472   },
473 
474   /** @private
475     Whenever content changes, setup and teardown observers on the content
476     as needed.
477   */
478   _scac_contentDidChange: function () {
479     this._scac_cached = NO; // invalidate observable content
480     var content     = this.get('content'),
481         lastContent = this._scac_content,
482         didChange   = this._scac_arrayContentDidChange,
483         willChange  = this._scac_arrayContentWillChange,
484         sfunc       = this._scac_contentStatusDidChange,
485         efunc       = this._scac_enumerableDidChange,
486         newlen;
487 
488     if (content === lastContent) { return this; } // nothing to do
489 
490     // teardown old observer
491     if (lastContent) {
492       if (lastContent.isSCArray) {
493         lastContent.removeArrayObservers({
494           target: this,
495           didChange: didChange,
496           willChange: willChange
497         });
498       } else if (lastContent.isEnumerable) {
499         lastContent.removeObserver('[]', this, efunc);
500       }
501 
502       lastContent.removeObserver('status', this, sfunc);
503 
504       this.teardownEnumerablePropertyChains(lastContent);
505     }
506 
507     // save new cached values
508     this._scac_cached = NO;
509     this._scac_content = content;
510 
511     // setup new observer
512     // also, calculate new length.  do it manually instead of using
513     // get(length) because we want to avoid computed an ordered array.
514     if (content) {
515       // Content is an enumerable, so listen for changes to its
516       // content, and get its length.
517       if (content.isSCArray) {
518         content.addArrayObservers({
519           target: this,
520           didChange: didChange,
521           willChange: willChange
522         });
523 
524         newlen = content.get('length');
525       } else if (content.isEnumerable) {
526         content.addObserver('[]', this, efunc);
527         newlen = content.get('length');
528       } else {
529         // Assume that someone has set a non-enumerable as the content, and
530         // treat it as the sole member of an array.
531         newlen = 1;
532       }
533 
534       // Observer for changes to the status property, in case this is an
535       // SC.Record or SC.RecordArray.
536       content.addObserver('status', this, sfunc);
537 
538       this.setupEnumerablePropertyChains(content);
539     } else {
540       newlen = SC.none(content) ? 0 : 1;
541     }
542 
543     // finally, notify enumerable content has changed.
544     this._scac_length = newlen;
545     this._scac_contentStatusDidChange();
546 
547     this.arrayContentDidChange(0, 0, newlen);
548     this.updateSelectionAfterContentChange();
549   }.observes('content'),
550 
551   /** @private
552     Whenever enumerable content changes, need to regenerate the
553     observableContent and notify that the range has changed.
554 
555     This is called whenever the content enumerable changes or whenever orderBy
556     changes.
557   */
558   _scac_enumerableDidChange: function () {
559     var content = this.get('content'), // use content directly
560         newlen  = content ? content.get('length') : 0,
561         oldlen  = this._scac_length;
562 
563     this._scac_length = newlen;
564     this._scac_cached = NO; // invalidate
565     // If this is an unordered enumerable, we have no way
566     // of knowing which indices changed. Instead, we just
567     // invalidate the whole array.
568     this.arrayContentWillChange(0, oldlen, newlen);
569     this.arrayContentDidChange(0, oldlen, newlen);
570     this.updateSelectionAfterContentChange();
571   }.observes('orderBy'),
572 
573   /** @private
574     Whenever the content "status" property changes, relay out.
575   */
576   _scac_contentStatusDidChange: function () {
577     this.notifyPropertyChange('status');
578   }
579 
580 });
581