PageRenderTime 39ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 0ms

/src/collection.js

https://github.com/Braunson/thorax
JavaScript | 334 lines | 290 code | 18 blank | 26 comment | 73 complexity | 201d300c957709698b27789ca5d5bf2e MD5 | raw file
  1. /*global createRegistryWrapper, dataObject, getEventCallback, getValue, modelCidAttributeName, viewCidAttributeName */
  2. var _fetch = Backbone.Collection.prototype.fetch,
  3. _reset = Backbone.Collection.prototype.reset,
  4. _replaceHTML = Thorax.View.prototype._replaceHTML,
  5. collectionCidAttributeName = 'data-collection-cid',
  6. collectionEmptyAttributeName = 'data-collection-empty',
  7. collectionElementAttributeName = 'data-collection-element',
  8. ELEMENT_NODE_TYPE = 1;
  9. Thorax.Collection = Backbone.Collection.extend({
  10. model: Thorax.Model || Backbone.Model,
  11. initialize: function() {
  12. this.cid = _.uniqueId('collection');
  13. return Backbone.Collection.prototype.initialize.apply(this, arguments);
  14. },
  15. isEmpty: function() {
  16. if (this.length > 0) {
  17. return false;
  18. } else {
  19. return this.length === 0 && this.isPopulated();
  20. }
  21. },
  22. isPopulated: function() {
  23. return this._fetched || this.length > 0 || (!this.length && !getValue(this, 'url'));
  24. },
  25. shouldFetch: function(options) {
  26. return options.fetch && !!getValue(this, 'url') && !this.isPopulated();
  27. },
  28. fetch: function(options) {
  29. options = options || {};
  30. var success = options.success;
  31. options.success = function(collection, response) {
  32. collection._fetched = true;
  33. success && success(collection, response);
  34. };
  35. return _fetch.apply(this, arguments);
  36. },
  37. reset: function(models, options) {
  38. this._fetched = !!models;
  39. return _reset.call(this, models, options);
  40. }
  41. });
  42. Thorax.Collections = {};
  43. createRegistryWrapper(Thorax.Collection, Thorax.Collections);
  44. dataObject('collection', {
  45. set: 'setCollection',
  46. bindCallback: onSetCollection,
  47. defaultOptions: {
  48. render: true,
  49. fetch: true,
  50. success: false,
  51. errors: true
  52. },
  53. change: onCollectionReset,
  54. $el: 'getCollectionElement',
  55. cidAttrName: collectionCidAttributeName
  56. });
  57. Thorax.CollectionView = Thorax.View.extend({
  58. _defaultTemplate: Handlebars.VM.noop,
  59. _collectionSelector: '[' + collectionElementAttributeName + ']',
  60. // preserve collection element if it was not created with {{collection}} helper
  61. _replaceHTML: function(html) {
  62. if (this.collection && this._objectOptionsByCid[this.collection.cid] && this._renderCount) {
  63. var element;
  64. var oldCollectionElement = this.getCollectionElement();
  65. element = _replaceHTML.call(this, html);
  66. if (!oldCollectionElement.attr('data-view-cid')) {
  67. this.getCollectionElement().replaceWith(oldCollectionElement);
  68. }
  69. } else {
  70. return _replaceHTML.call(this, html);
  71. }
  72. },
  73. //appendItem(model [,index])
  74. //appendItem(html_string, index)
  75. //appendItem(view, index)
  76. appendItem: function(model, index, options) {
  77. //empty item
  78. if (!model) {
  79. return;
  80. }
  81. var itemView,
  82. $el = this.getCollectionElement();
  83. options = _.defaults(options || {}, {
  84. filter: true
  85. });
  86. //if index argument is a view
  87. index && index.el && (index = $el.children().indexOf(index.el) + 1);
  88. //if argument is a view, or html string
  89. if (model.el || _.isString(model)) {
  90. itemView = model;
  91. model = false;
  92. } else {
  93. index = index || this.collection.indexOf(model) || 0;
  94. itemView = this.renderItem(model, index);
  95. }
  96. if (itemView) {
  97. itemView.cid && this._addChild(itemView);
  98. //if the renderer's output wasn't contained in a tag, wrap it in a div
  99. //plain text, or a mixture of top level text nodes and element nodes
  100. //will get wrapped
  101. if (_.isString(itemView) && !itemView.match(/^\s*</m)) {
  102. itemView = '<div>' + itemView + '</div>';
  103. }
  104. var itemElement = itemView.el ? [itemView.el] : _.filter($($.trim(itemView)), function(node) {
  105. //filter out top level whitespace nodes
  106. return node.nodeType === ELEMENT_NODE_TYPE;
  107. });
  108. model && $(itemElement).attr(modelCidAttributeName, model.cid);
  109. var previousModel = index > 0 ? this.collection.at(index - 1) : false;
  110. if (!previousModel) {
  111. $el.prepend(itemElement);
  112. } else {
  113. //use last() as appendItem can accept multiple nodes from a template
  114. var last = $el.children('[' + modelCidAttributeName + '="' + previousModel.cid + '"]').last();
  115. last.after(itemElement);
  116. }
  117. this.trigger('append', null, function(el) {
  118. el.setAttribute(modelCidAttributeName, model.cid);
  119. });
  120. !options.silent && this.trigger('rendered:item', this, this.collection, model, itemElement, index);
  121. options.filter && applyItemVisiblityFilter.call(this, model);
  122. }
  123. return itemView;
  124. },
  125. // updateItem only useful if there is no item view, otherwise
  126. // itemView.render() provides the same functionality
  127. updateItem: function(model) {
  128. this.removeItem(model);
  129. this.appendItem(model);
  130. },
  131. removeItem: function(model) {
  132. var $el = this.getCollectionElement(),
  133. viewEl = $el.find('[' + modelCidAttributeName + '="' + model.cid + '"]');
  134. if (!viewEl.length) {
  135. return false;
  136. }
  137. viewEl.remove();
  138. var viewCid = viewEl.attr(viewCidAttributeName),
  139. child = this.children[viewCid];
  140. if (child) {
  141. this._removeChild(child);
  142. child.destroy();
  143. }
  144. return true;
  145. },
  146. renderCollection: function() {
  147. if (this.collection) {
  148. if (this.collection.isEmpty()) {
  149. handleChangeFromNotEmptyToEmpty.call(this);
  150. } else {
  151. handleChangeFromEmptyToNotEmpty.call(this);
  152. this.collection.forEach(function(item, i) {
  153. this.appendItem(item, i);
  154. }, this);
  155. }
  156. this.trigger('rendered:collection', this, this.collection);
  157. applyVisibilityFilter.call(this);
  158. } else {
  159. handleChangeFromNotEmptyToEmpty.call(this);
  160. }
  161. },
  162. emptyClass: 'empty',
  163. renderEmpty: function() {
  164. if (!this.emptyTemplate && !this.emptyView) {
  165. assignTemplate.call(this, 'emptyTemplate', {
  166. extension: '-empty',
  167. required: false
  168. });
  169. }
  170. if (this.emptyView) {
  171. var viewOptions = {};
  172. if (this.emptyTemplate) {
  173. viewOptions.template = this.emptyTemplate;
  174. }
  175. var view = Thorax.Util.getViewInstance(this.emptyView, viewOptions);
  176. view.ensureRendered();
  177. return view;
  178. } else {
  179. return this.emptyTemplate && this.renderTemplate(this.emptyTemplate);
  180. }
  181. },
  182. renderItem: function(model, i) {
  183. if (!this.itemTemplate && !this.itemView) {
  184. assignTemplate.call(this, 'itemTemplate', {
  185. extension: '-item',
  186. // only require an itemTemplate if an itemView
  187. // is not present
  188. required: !this.itemView
  189. });
  190. }
  191. if (this.itemView) {
  192. var viewOptions = {
  193. model: model
  194. };
  195. if (this.itemTemplate) {
  196. viewOptions.template = this.itemTemplate;
  197. }
  198. var view = Thorax.Util.getViewInstance(this.itemView, viewOptions);
  199. view.ensureRendered();
  200. return view;
  201. } else {
  202. return this.renderTemplate(this.itemTemplate, this.itemContext(model, i));
  203. }
  204. },
  205. itemContext: function(model /*, i */) {
  206. return model.attributes;
  207. },
  208. appendEmpty: function() {
  209. var $el = this.getCollectionElement();
  210. $el.empty();
  211. var emptyContent = this.renderEmpty();
  212. emptyContent && this.appendItem(emptyContent, 0, {
  213. silent: true,
  214. filter: false
  215. });
  216. this.trigger('rendered:empty', this, this.collection);
  217. },
  218. getCollectionElement: function() {
  219. var element = this.$(this._collectionSelector);
  220. return element.length === 0 ? this.$el : element;
  221. }
  222. });
  223. Thorax.CollectionView.on({
  224. collection: {
  225. reset: onCollectionReset,
  226. sort: onCollectionReset,
  227. filter: function() {
  228. applyVisibilityFilter.call(this);
  229. },
  230. change: function(model) {
  231. // If we rendered with item views, model changes will be observed
  232. // by the generated item view but if we rendered with templates
  233. // then model changes need to be bound as nothing is watching
  234. !this.itemView && this.updateItem(model);
  235. applyItemVisiblityFilter.call(this, model);
  236. },
  237. add: function(model) {
  238. var $el = this.getCollectionElement();
  239. this.collection.length === 1 && $el.length && handleChangeFromEmptyToNotEmpty.call(this);
  240. if ($el.length) {
  241. var index = this.collection.indexOf(model);
  242. this.appendItem(model, index);
  243. }
  244. },
  245. remove: function(model) {
  246. var $el = this.getCollectionElement();
  247. this.removeItem(model);
  248. this.collection.length === 0 && $el.length && handleChangeFromNotEmptyToEmpty.call(this);
  249. }
  250. }
  251. });
  252. Thorax.View.on({
  253. collection: {
  254. error: function(collection, message) {
  255. if (this._objectOptionsByCid[collection.cid].errors) {
  256. this.trigger('error', message, collection);
  257. }
  258. }
  259. }
  260. });
  261. function onCollectionReset(collection) {
  262. var options = collection && this._objectOptionsByCid[collection.cid];
  263. // we would want to still render in the case that the
  264. // collection has transitioned to being falsy
  265. if (!collection || (options && options.render)) {
  266. this.renderCollection && this.renderCollection();
  267. }
  268. }
  269. // Even if the view is not a CollectionView
  270. // ensureRendered() to provide similar behavior
  271. // to a model
  272. function onSetCollection() {
  273. this.ensureRendered();
  274. }
  275. function applyVisibilityFilter() {
  276. if (this.itemFilter) {
  277. this.collection.forEach(function(model) {
  278. applyItemVisiblityFilter.call(this, model);
  279. }, this);
  280. }
  281. }
  282. function applyItemVisiblityFilter(model) {
  283. var $el = this.getCollectionElement();
  284. this.itemFilter && $el.find('[' + modelCidAttributeName + '="' + model.cid + '"]')[itemShouldBeVisible.call(this, model) ? 'show' : 'hide']();
  285. }
  286. function itemShouldBeVisible(model) {
  287. return this.itemFilter(model, this.collection.indexOf(model));
  288. }
  289. function handleChangeFromEmptyToNotEmpty() {
  290. var $el = this.getCollectionElement();
  291. this.emptyClass && $el.removeClass(this.emptyClass);
  292. $el.removeAttr(collectionEmptyAttributeName);
  293. $el.empty();
  294. }
  295. function handleChangeFromNotEmptyToEmpty() {
  296. var $el = this.getCollectionElement();
  297. this.emptyClass && $el.addClass(this.emptyClass);
  298. $el.attr(collectionEmptyAttributeName, true);
  299. this.appendEmpty();
  300. }
  301. //$(selector).collection() helper
  302. $.fn.collection = function(view) {
  303. if (view && view.collection) {
  304. return view.collection;
  305. }
  306. var $this = $(this),
  307. collectionElement = $this.closest('[' + collectionCidAttributeName + ']'),
  308. collectionCid = collectionElement && collectionElement.attr(collectionCidAttributeName);
  309. if (collectionCid) {
  310. view = $this.view();
  311. if (view) {
  312. return view.collection;
  313. }
  314. }
  315. return false;
  316. };