PageRenderTime 27ms CodeModel.GetById 20ms RepoModel.GetById 1ms app.codeStats 0ms

/src/js/cilantro/ui/controls/vocab.js

https://github.com/cbmi/cilantro
JavaScript | 528 lines | 370 code | 110 blank | 48 comment | 24 complexity | 853fa87bdaf1031d7fa7bf551567ff27 MD5 | raw file
  1. /* global define */
  2. define([
  3. 'jquery',
  4. 'underscore',
  5. 'backbone',
  6. 'marionette',
  7. './base',
  8. './search',
  9. '../search',
  10. '../values',
  11. '../paginator',
  12. '../../models',
  13. ], function($, _, Backbone, Marionette, controls, searchControl, search, values,
  14. paginator, models) {
  15. // Collection that listens to another collection for models that match
  16. // certain criteria. Models that match will be added/removed from
  17. // this collection.
  18. var FilteredCollection = Backbone.Collection.extend({
  19. _matches: function(model) {
  20. var attrs = model.attributes || model;
  21. return this.matcher(attrs);
  22. },
  23. initialize: function(models, options) {
  24. this.matcher = options.matcher;
  25. this.values = options.collection;
  26. this.listenTo(this.values, {
  27. change: function(model, options) {
  28. if (this._matches(model)) {
  29. this.add(model, options);
  30. } else {
  31. this.remove(model, options);
  32. }
  33. },
  34. add: function(model, collection, options) {
  35. if (this._matches(model)) this.add(model, options);
  36. },
  37. remove: function(model, collection, options) {
  38. if (this._matches(model)) this.remove(model, options);
  39. },
  40. reset: function(models, collection, options) {
  41. var _models = null;
  42. if (!models || !models.length) {
  43. _models = [];
  44. } else {
  45. _models = _.filter(models, this._matches);
  46. }
  47. if (_models) this.reset(_models, options);
  48. }
  49. });
  50. }
  51. });
  52. // Extend value model to ensure the default operator is defined
  53. var BucketValue = models.Value.extend({
  54. defaults: {
  55. operator: 'in'
  56. }
  57. });
  58. var BucketValues = models.Values.extend({
  59. model: BucketValue
  60. });
  61. // Single item in a bucket
  62. var BucketItem = values.ValueItem.extend({
  63. template: 'controls/vocab/bucket-item',
  64. ui: {
  65. remove: '[data-target=remove]'
  66. },
  67. events: {
  68. 'click @ui.remove': 'removeItem'
  69. },
  70. removeItem: function() {
  71. this.model.destroy();
  72. }
  73. });
  74. // Buckets are implicitly bound to an operator via the passed collection.
  75. // The bucket name must be passed as an option.
  76. var Bucket = Marionette.CompositeView.extend({
  77. className: 'vocab-bucket',
  78. template: 'controls/vocab/bucket',
  79. itemView: BucketItem,
  80. itemViewContainer: '[data-target=items]',
  81. options: {
  82. name: 'Bucket'
  83. },
  84. ui: {
  85. items: '[data-target=items]'
  86. },
  87. events: {
  88. 'sortreceive [data-target=items]': 'receiveItem'
  89. },
  90. collectionEvents: {
  91. 'add remove reset': 'renderListState'
  92. },
  93. serializeData: function() {
  94. return {
  95. name: this.options.name
  96. };
  97. },
  98. // jQuery UI does not trigger an event on the item itself, only the
  99. // lists themselves. The `ui.item` is the bucket item DOM element
  100. // which can be used to find the collection item.
  101. receiveItem: function(event, ui) {
  102. // Find the model based on the value
  103. var value = ui.item.find('[data-value]').data('value'),
  104. model = this.collection.values.findWhere({value: parseInt(value, 10)});
  105. model.set('operator', this.options.operator);
  106. },
  107. onRender: function() {
  108. this.ui.items.sortable({
  109. forcePlaceholderSize: true,
  110. forceHelperSize: true,
  111. placeholder: 'placeholder',
  112. scroll: false,
  113. opacity: 0.7,
  114. cursor: 'move',
  115. connectWith: '.vocab-bucket [data-target=items]'
  116. });
  117. this.renderListState();
  118. },
  119. renderListState: function() {
  120. this.$el.toggleClass('empty', this.collection.length === 0);
  121. }
  122. });
  123. // View that initializes a filtered collection and renders a bucket for each
  124. // available operator.
  125. var BucketList = Marionette.View.extend({
  126. initialize: function() {
  127. var name = this.model.get('alt_name').toLowerCase(),
  128. pluralName = this.model.get('alt_plural_name').toLowerCase();
  129. this.buckets = [{
  130. name: 'At least one ' + name + ' must match',
  131. operator: 'in',
  132. collection: new FilteredCollection(null, {
  133. collection: this.collection,
  134. matcher: function(attrs) {
  135. return attrs.operator === 'in';
  136. }
  137. })
  138. }, {
  139. name: 'All the ' + pluralName + ' must match',
  140. operator: 'all',
  141. collection: new FilteredCollection(null, {
  142. collection: this.collection,
  143. matcher: function(attrs) {
  144. return attrs.operator === 'all';
  145. }
  146. })
  147. }, {
  148. name: 'The combination of '+ pluralName + ' cannot match',
  149. operator: '-all',
  150. collection: new FilteredCollection(null, {
  151. collection: this.collection,
  152. matcher: function(attrs) {
  153. return attrs.operator === '-all';
  154. }
  155. })
  156. }, {
  157. name: 'None of the ' + pluralName + ' can match',
  158. operator: '-in',
  159. collection: new FilteredCollection(null, {
  160. collection: this.collection,
  161. matcher: function(attrs) {
  162. return attrs.operator === '-in';
  163. }
  164. })
  165. }];
  166. },
  167. render: function() {
  168. for (var i = 0; i < this.buckets.length; i++) {
  169. var bucket = new Bucket(this.buckets[i]);
  170. bucket.render();
  171. this.$el.append(bucket.el);
  172. }
  173. return this.el;
  174. }
  175. });
  176. // Region displaying the current path and button for going up the stack
  177. var Path = Marionette.ItemView.extend({
  178. template: 'controls/vocab/path',
  179. className: 'vocab-path'
  180. });
  181. // Single search result item
  182. var VocabItem = Marionette.ItemView.extend({
  183. className: 'value-item',
  184. template: 'controls/vocab/item',
  185. ui: {
  186. actions: '.actions',
  187. addButton: '.add-item-button',
  188. removeButton: '.remove-item-button'
  189. },
  190. events: {
  191. 'click .add-item-button': 'addItem',
  192. 'click .remove-item-button': 'removeItem'
  193. },
  194. constructor: function(options) {
  195. options = options || {};
  196. if ((this.values = options.values)) {
  197. this.listenTo(this.values, 'add', this.setState);
  198. this.listenTo(this.values, 'remove', this.setState);
  199. this.listenTo(this.values, 'reset', this.setState);
  200. }
  201. Marionette.ItemView.prototype.constructor.call(this, options);
  202. },
  203. serializeData: function() {
  204. var url;
  205. // 2.3.x compatibility
  206. if (this.model.attributes._links) {
  207. var link = this.model.get('_links').children;
  208. url = link ? link.href : null;
  209. }
  210. else {
  211. url = this.model.links.children;
  212. }
  213. return {
  214. url: url,
  215. label: this.model.get('label')
  216. };
  217. },
  218. addItem: function() {
  219. // Mark as valid since it was derived from a controlled list
  220. var attrs = _.extend(this.model.toJSON(), {valid: true});
  221. this.values.add(attrs);
  222. },
  223. removeItem: function() {
  224. this.values.remove(this.model);
  225. },
  226. setState: function() {
  227. if (!!this.values.get(this.model)) {
  228. this.ui.addButton.hide();
  229. this.ui.removeButton.show();
  230. } else {
  231. this.ui.addButton.show();
  232. this.ui.removeButton.hide();
  233. }
  234. },
  235. onRender: function() {
  236. this.setState();
  237. }
  238. });
  239. // A single page of search results
  240. var VocabPage = paginator.ListingPage.extend({
  241. className: 'search-value-list',
  242. itemView: VocabItem
  243. });
  244. // All search result pages, only the current page is shown, the rest are
  245. // hidden.
  246. var VocabPageRoll = paginator.PageRoll.extend({
  247. listView: VocabPage
  248. });
  249. var VocabControl = controls.ControlLayout.extend({
  250. className: 'vocab-control',
  251. template: 'controls/vocab/layout',
  252. events: {
  253. 'click [data-action=clear]': 'clearValues',
  254. 'click .browse-region a': 'triggerDescend',
  255. 'click .path-region button': 'triggerAscend'
  256. },
  257. options: {
  258. resetPathOnSearch: false
  259. },
  260. regions: {
  261. paginator: '.paginator-region',
  262. search: '.search-region',
  263. path: '.path-region',
  264. browse: '.browse-region',
  265. buckets: '.buckets-region'
  266. },
  267. regionViews: {
  268. search: search.Search,
  269. paginator: paginator.Paginator,
  270. path: Path,
  271. browse: VocabPageRoll,
  272. buckets: BucketList
  273. },
  274. initialize: function() {
  275. // Initialize a new collection of values that centralizes the
  276. // selected values.
  277. this.selectedValues = new BucketValues();
  278. // Trigger a change event on all collection events
  279. this.listenTo(this.selectedValues, 'all', this.change);
  280. this.valuesPaginator = new searchControl.SearchPaginator(null, {
  281. field: this.model
  282. });
  283. this._path = new Backbone.Collection();
  284. },
  285. triggerDescend: function(event) {
  286. event.preventDefault();
  287. event.stopPropagation();
  288. var target = $(event.target);
  289. // Push item onto the stack; triggers refresh downstream
  290. this._path.push({
  291. label: target.text(),
  292. url: target.prop('href')
  293. });
  294. },
  295. triggerAscend: function() {
  296. event.preventDefault();
  297. event.stopPropagation();
  298. // Popitem off stack; triggers refresh downstream
  299. this._path.pop();
  300. },
  301. refreshPaginator: function() {
  302. var model = this._path.last();
  303. this.path.show(new this.regionViews.path({
  304. model: model
  305. }));
  306. this.valuesPaginator.currentUrl = model.get('url');
  307. this.valuesPaginator.refresh();
  308. },
  309. onRender: function() {
  310. // When a search occurs, the paginator is reset to use the
  311. // URL with the parameters. When the search is cleared, the
  312. // default URL is used (accessing the root).
  313. var searchRegion = new this.regionViews.search({
  314. model: this.model,
  315. placeholder: 'Search ' + this.model.get('plural_name') + '...',
  316. });
  317. this.listenTo(searchRegion, 'search', this.handleSearch);
  318. // Click events from valid path links in the browse region
  319. // will cause the paginator to refresh
  320. var browseRegion = new this.regionViews.browse({
  321. collection: this.valuesPaginator,
  322. values: this.selectedValues
  323. });
  324. var paginatorRegion = new this.regionViews.paginator({
  325. className: 'paginator mini',
  326. model: this.valuesPaginator
  327. });
  328. var bucketsRegion = new this.regionViews.buckets({
  329. model: this.model,
  330. collection: this.selectedValues
  331. });
  332. this.search.show(searchRegion);
  333. this.browse.show(browseRegion);
  334. this.paginator.show(paginatorRegion);
  335. this.buckets.show(bucketsRegion);
  336. this.listenTo(this._path, 'add remove', this.refreshPaginator);
  337. // Add top-level (default) which will render
  338. this._path.push({label: 'Top-level'});
  339. },
  340. handleSearch: function(query) {
  341. if (this.options.resetPathOnSearch) {
  342. this.valuesPaginator.currentUrl = null;
  343. }
  344. this.valuesPaginator.urlParams = query ? {query: query} : null;
  345. this.valuesPaginator.refresh();
  346. },
  347. get: function() {
  348. // Object of operator keys and array values
  349. var operator, values = {};
  350. // Build operator/values map
  351. this.selectedValues.each(function(model) {
  352. operator = model.get('operator');
  353. // Initialize the array if this is the first value
  354. // for this operator
  355. if (!values[operator]) values[operator] = [];
  356. values[operator].push(model.pick('label', 'value'));
  357. });
  358. var operators = _.keys(values);
  359. // No values selected
  360. if (!operators.length) return;
  361. // Single operator, return single condition
  362. if (operators.length === 1) {
  363. return {
  364. field: this.model.id,
  365. operator: operators[0],
  366. value: values[operators[0]]
  367. };
  368. }
  369. // Multiple operators, return branch of conditions
  370. return {
  371. type: 'and',
  372. children: _.map(values, function(values, operator) {
  373. return {
  374. field: this.model.id,
  375. operator: operator,
  376. value: values
  377. };
  378. }, this)
  379. };
  380. },
  381. _mapSetValues: function(values, operator) {
  382. return _.map(values, function(value) {
  383. // Value is expected to be an object with label and value keys.
  384. value = _.clone(value);
  385. value.operator = operator;
  386. return value;
  387. });
  388. },
  389. // The expected structure is either null, a single condition or
  390. // a branch of one or more conditions. Values are collected and
  391. // tagged with the operator that is applied to the values.
  392. set: function(attrs) {
  393. if (!attrs) return;
  394. var values = [];
  395. if (attrs.children) {
  396. // Each child is a condition with an array of values
  397. _.each(attrs.children, function(child) {
  398. values = values.concat(this._mapSetValues(child.value,
  399. child.operator));
  400. }, this);
  401. } else {
  402. values = this._mapSetValues(attrs.value, attrs.operator);
  403. }
  404. // Do not remove values in case they are new since the set
  405. // has occurred.
  406. this.selectedValues.set(values, {remove: false});
  407. },
  408. isEmpty: function(attrs) {
  409. // If children are defined than more than one operator is
  410. // in use which means values have been selected.
  411. if (attrs.children) return false;
  412. return controls.ControlLayout.prototype.isEmpty.call(this, attrs);
  413. }
  414. });
  415. return {
  416. VocabControl: VocabControl
  417. };
  418. });