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