PageRenderTime 51ms CodeModel.GetById 22ms RepoModel.GetById 0ms app.codeStats 0ms

/web/sites/all/modules/civicrm/js/crm.backbone.js

https://gitlab.com/jamie/ussocialforum
JavaScript | 571 lines | 334 code | 21 blank | 216 comment | 61 complexity | dd5122e882ab3c0b1ccfe1c2471e9993 MD5 | raw file
Possible License(s): GPL-2.0, MIT, LGPL-2.1, MPL-2.0-no-copyleft-exception, BSD-3-Clause, Apache-2.0, AGPL-1.0
  1. (function($, _) {
  2. if (!CRM.Backbone) CRM.Backbone = {};
  3. /**
  4. * Backbone.sync provider which uses CRM.api() for I/O.
  5. * To support CRUD operations, model classes must be defined with a "crmEntityName" property.
  6. * To load collections using API queries, set the "crmCriteria" property or override the
  7. * method "toCrmCriteria".
  8. *
  9. * @param method Accepts normal Backbone.sync methods; also accepts "crm-replace"
  10. * @param model
  11. * @param options
  12. * @see tests/qunit/crm-backbone
  13. */
  14. CRM.Backbone.sync = function(method, model, options) {
  15. var isCollection = _.isArray(model.models);
  16. if (isCollection) {
  17. var apiOptions = {
  18. success: function(data) {
  19. // unwrap data
  20. options.success(_.toArray(data.values));
  21. },
  22. error: function(data) {
  23. // CRM.api displays errors by default, but Backbone.sync
  24. // protocol requires us to override "error". This restores
  25. // the default behavior.
  26. $().crmError(data.error_message, ts('Error'));
  27. options.error(data);
  28. }
  29. };
  30. switch (method) {
  31. case 'read':
  32. CRM.api(model.crmEntityName, model.toCrmAction('get'), model.toCrmCriteria(), apiOptions);
  33. break;
  34. // replace all entities matching "x.crmCriteria" with new entities in "x.models"
  35. case 'crm-replace':
  36. var params = this.toCrmCriteria();
  37. params.version = 3;
  38. params.values = this.toJSON();
  39. CRM.api(model.crmEntityName, model.toCrmAction('replace'), params, apiOptions);
  40. break;
  41. default:
  42. apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
  43. break;
  44. }
  45. } else {
  46. // callback options to pass to CRM.api
  47. var apiOptions = {
  48. success: function(data) {
  49. // unwrap data
  50. var values = _.toArray(data['values']);
  51. if (data.count == 1) {
  52. options.success(values[0]);
  53. } else {
  54. data.is_error = 1;
  55. data.error_message = ts("Expected exactly one response");
  56. apiOptions.error(data);
  57. }
  58. },
  59. error: function(data) {
  60. // CRM.api displays errors by default, but Backbone.sync
  61. // protocol requires us to override "error". This restores
  62. // the default behavior.
  63. $().crmError(data.error_message, ts('Error'));
  64. options.error(data);
  65. }
  66. };
  67. switch (method) {
  68. case 'create': // pass-through
  69. case 'update':
  70. var params = model.toJSON();
  71. params.options || (params.options = {});
  72. params.options.reload = 1;
  73. if (!model._isDuplicate) {
  74. CRM.api(model.crmEntityName, model.toCrmAction('create'), params, apiOptions);
  75. } else {
  76. CRM.api(model.crmEntityName, model.toCrmAction('duplicate'), params, apiOptions);
  77. }
  78. break;
  79. case 'read':
  80. case 'delete':
  81. var apiAction = (method == 'delete') ? 'delete' : 'get';
  82. var params = model.toCrmCriteria();
  83. if (!params.id) {
  84. apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
  85. return;
  86. }
  87. CRM.api(model.crmEntityName, model.toCrmAction(apiAction), params, apiOptions);
  88. break;
  89. default:
  90. apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
  91. }
  92. }
  93. };
  94. /**
  95. * Connect a "model" class to CiviCRM's APIv3
  96. *
  97. * @code
  98. * // Setup class
  99. * var ContactModel = Backbone.Model.extend({});
  100. * CRM.Backbone.extendModel(ContactModel, "Contact");
  101. *
  102. * // Use class
  103. * c = new ContactModel({id: 3});
  104. * c.fetch();
  105. * @endcode
  106. *
  107. * @param Class ModelClass
  108. * @param string crmEntityName APIv3 entity name, such as "Contact" or "CustomField"
  109. * @see tests/qunit/crm-backbone
  110. */
  111. CRM.Backbone.extendModel = function(ModelClass, crmEntityName) {
  112. // Defaults - if specified in ModelClass, preserve
  113. _.defaults(ModelClass.prototype, {
  114. crmEntityName: crmEntityName,
  115. crmActions: {}, // map: string backboneActionName => string serverSideActionName
  116. crmReturn: null, // array: list of fields to return
  117. toCrmAction: function(action) {
  118. return this.crmActions[action] ? this.crmActions[action] : action;
  119. },
  120. toCrmCriteria: function() {
  121. var result = (this.get('id')) ? {id: this.get('id')} : {};
  122. if (this.crmReturn != null) {
  123. result.return = this.crmReturn;
  124. }
  125. return result;
  126. },
  127. duplicate: function() {
  128. var newModel = new ModelClass(this.toJSON());
  129. newModel._isDuplicate = true;
  130. if (newModel.setModified) newModel.setModified();
  131. newModel.listenTo(newModel, 'sync', function(){
  132. // may get called on subsequent resaves -- don't care!
  133. delete newModel._isDuplicate;
  134. });
  135. return newModel;
  136. }
  137. });
  138. // Overrides - if specified in ModelClass, replace
  139. _.extend(ModelClass.prototype, {
  140. sync: CRM.Backbone.sync
  141. });
  142. };
  143. /**
  144. * Configure a model class to track whether a model has unsaved changes.
  145. *
  146. * Methods:
  147. * - setModified() - flag the model as modified/dirty
  148. * - isSaved() - return true if there have been no changes to the data since the last fetch or save
  149. * Events:
  150. * - saved(object model, bool is_saved) - triggered whenever isSaved() value would change
  151. *
  152. * Note: You should not directly call isSaved() within the context of the success/error/sync callback;
  153. * I haven't found a way to make isSaved() behave correctly within these callbacks without patching
  154. * Backbone. Instead, attach an event listener to the 'saved' event.
  155. *
  156. * @param ModelClass
  157. */
  158. CRM.Backbone.trackSaved = function(ModelClass) {
  159. // Retain references to some of the original class's functions
  160. var Parent = _.pick(ModelClass.prototype, 'initialize', 'save', 'fetch');
  161. // Private callback
  162. var onSyncSuccess = function() {
  163. this._modified = false;
  164. if (this._oldModified.length > 0) {
  165. this._oldModified.pop();
  166. }
  167. this.trigger('saved', this, this.isSaved());
  168. };
  169. var onSaveError = function() {
  170. if (this._oldModified.length > 0) {
  171. this._modified = this._oldModified.pop();
  172. this.trigger('saved', this, this.isSaved());
  173. }
  174. };
  175. // Defaults - if specified in ModelClass, preserve
  176. _.defaults(ModelClass.prototype, {
  177. isSaved: function() {
  178. var result = !this.isNew() && !this.isModified();
  179. return result;
  180. },
  181. isModified: function() {
  182. return this._modified;
  183. },
  184. _saved_onchange: function(model, options) {
  185. if (options.parse) return;
  186. // console.log('change', model.changedAttributes(), model.previousAttributes());
  187. this.setModified();
  188. },
  189. setModified: function() {
  190. var oldModified = this._modified;
  191. this._modified = true;
  192. if (!oldModified) {
  193. this.trigger('saved', this, this.isSaved());
  194. }
  195. }
  196. });
  197. // Overrides - if specified in ModelClass, replace
  198. _.extend(ModelClass.prototype, {
  199. initialize: function(options) {
  200. this._modified = false;
  201. this._oldModified = [];
  202. this.listenTo(this, 'change', this._saved_onchange);
  203. this.listenTo(this, 'error', onSaveError);
  204. this.listenTo(this, 'sync', onSyncSuccess);
  205. if (Parent.initialize) {
  206. return Parent.initialize.apply(this, arguments);
  207. }
  208. },
  209. save: function() {
  210. // we'll assume success
  211. this._oldModified.push(this._modified);
  212. return Parent.save.apply(this, arguments);
  213. },
  214. fetch: function() {
  215. this._oldModified.push(this._modified);
  216. return Parent.fetch.apply(this, arguments);
  217. }
  218. });
  219. };
  220. /**
  221. * Configure a model class to support client-side soft deletion.
  222. * One can call "model.setDeleted(BOOLEAN)" to flag an entity for
  223. * deletion (or not) -- however, deletion will be deferred until save()
  224. * is called.
  225. *
  226. * Methods:
  227. * setSoftDeleted(boolean) - flag the model as deleted (or not-deleted)
  228. * isSoftDeleted() - determine whether model has been soft-deleted
  229. * Events:
  230. * softDelete(model, is_deleted) -- change value of is_deleted
  231. *
  232. * @param ModelClass
  233. */
  234. CRM.Backbone.trackSoftDelete = function(ModelClass) {
  235. // Retain references to some of the original class's functions
  236. var Parent = _.pick(ModelClass.prototype, 'save');
  237. // Defaults - if specified in ModelClass, preserve
  238. _.defaults(ModelClass.prototype, {
  239. is_soft_deleted: false,
  240. setSoftDeleted: function(is_deleted) {
  241. if (this.is_soft_deleted != is_deleted) {
  242. this.is_soft_deleted = is_deleted;
  243. this.trigger('softDelete', this, is_deleted);
  244. if (this.setModified) this.setModified(); // FIXME: ugly interaction, trackSoftDelete-trackSaved
  245. }
  246. },
  247. isSoftDeleted: function() {
  248. return this.is_soft_deleted;
  249. }
  250. });
  251. // Overrides - if specified in ModelClass, replace
  252. _.extend(ModelClass.prototype, {
  253. save: function(attributes, options) {
  254. if (this.isSoftDeleted()) {
  255. return this.destroy(options);
  256. } else {
  257. return Parent.save.apply(this, arguments);
  258. }
  259. }
  260. });
  261. };
  262. /**
  263. * Connect a "collection" class to CiviCRM's APIv3
  264. *
  265. * Note: the collection supports a special property, crmCriteria, which is an array of
  266. * query options to send to the API.
  267. *
  268. * @code
  269. * // Setup class
  270. * var ContactModel = Backbone.Model.extend({});
  271. * CRM.Backbone.extendModel(ContactModel, "Contact");
  272. * var ContactCollection = Backbone.Collection.extend({
  273. * model: ContactModel
  274. * });
  275. * CRM.Backbone.extendCollection(ContactCollection);
  276. *
  277. * // Use class (with passive criteria)
  278. * var c = new ContactCollection([], {
  279. * crmCriteria: {contact_type: 'Organization'}
  280. * });
  281. * c.fetch();
  282. * c.get(123).set('property', 'value');
  283. * c.get(456).setDeleted(true);
  284. * c.save();
  285. *
  286. * // Use class (with active criteria)
  287. * var criteriaModel = new SomeModel({
  288. * contact_type: 'Organization'
  289. * });
  290. * var c = new ContactCollection([], {
  291. * crmCriteriaModel: criteriaModel
  292. * });
  293. * c.fetch();
  294. * c.get(123).set('property', 'value');
  295. * c.get(456).setDeleted(true);
  296. * c.save();
  297. * @endcode
  298. *
  299. *
  300. * @param Class CollectionClass
  301. * @see tests/qunit/crm-backbone
  302. */
  303. CRM.Backbone.extendCollection = function(CollectionClass) {
  304. var origInit = CollectionClass.prototype.initialize;
  305. // Defaults - if specified in CollectionClass, preserve
  306. _.defaults(CollectionClass.prototype, {
  307. crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
  308. crmActions: {}, // map: string backboneActionName => string serverSideActionName
  309. toCrmAction: function(action) {
  310. return this.crmActions[action] ? this.crmActions[action] : action;
  311. },
  312. toCrmCriteria: function() {
  313. var result = (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
  314. if (this.crmReturn != null) {
  315. result.return = this.crmReturn;
  316. } else if (this.model && this.model.prototype.crmReturn != null) {
  317. result.return = this.model.prototype.crmReturn;
  318. }
  319. return result;
  320. },
  321. /**
  322. * Get an object which represents this collection's criteria
  323. * as a live model. Any changes to the model will be applied
  324. * to the collection, and the collection will be refreshed.
  325. *
  326. * @param criteriaModelClass
  327. */
  328. setCriteriaModel: function(criteriaModel) {
  329. var collection = this;
  330. this.crmCriteria = criteriaModel.toJSON();
  331. this.listenTo(criteriaModel, 'change', function() {
  332. collection.crmCriteria = criteriaModel.toJSON();
  333. collection.debouncedFetch();
  334. });
  335. },
  336. debouncedFetch: _.debounce(function() {
  337. this.fetch({reset: true});
  338. }, 100),
  339. /**
  340. * Reconcile the server's collection with the client's collection.
  341. * New/modified items from the client will be saved/updated on the
  342. * server. Deleted items from the client will be deleted on the
  343. * server.
  344. *
  345. * @param Object options - accepts "success" and "error" callbacks
  346. */
  347. save: function(options) {
  348. options || (options = {});
  349. var collection = this;
  350. var success = options.success;
  351. options.success = function(resp) {
  352. // Ensure attributes are restored during synchronous saves.
  353. collection.reset(resp, options);
  354. if (success) success(collection, resp, options);
  355. // collection.trigger('sync', collection, resp, options);
  356. };
  357. wrapError(collection, options);
  358. return this.sync('crm-replace', this, options)
  359. }
  360. });
  361. // Overrides - if specified in CollectionClass, replace
  362. _.extend(CollectionClass.prototype, {
  363. sync: CRM.Backbone.sync,
  364. initialize: function(models, options) {
  365. options || (options = {});
  366. if (options.crmCriteriaModel) {
  367. this.setCriteriaModel(options.crmCriteriaModel);
  368. } else if (options.crmCriteria) {
  369. this.crmCriteria = options.crmCriteria;
  370. }
  371. if (options.crmActions) {
  372. this.crmActions = _.extend(this.crmActions, options.crmActions);
  373. }
  374. if (origInit) {
  375. return origInit.apply(this, arguments);
  376. }
  377. },
  378. toJSON: function() {
  379. var result = [];
  380. // filter models list, excluding any soft-deleted items
  381. this.each(function(model) {
  382. // if model doesn't track soft-deletes
  383. // or if model tracks soft-deletes and wasn't soft-deleted
  384. if (!model.isSoftDeleted || !model.isSoftDeleted()) {
  385. result.push(model.toJSON());
  386. }
  387. });
  388. return result;
  389. }
  390. });
  391. };
  392. /**
  393. * Find a single record, or create a new record.
  394. *
  395. * @param Object options:
  396. * - CollectionClass: class
  397. * - crmCriteria: Object values to search/default on
  398. * - defaults: Object values to put on newly created model (if needed)
  399. * - success: function(model)
  400. * - error: function(collection, error)
  401. */
  402. CRM.Backbone.findCreate = function(options) {
  403. options || (options = {});
  404. var collection = new options.CollectionClass([], {
  405. crmCriteria: options.crmCriteria
  406. });
  407. collection.fetch({
  408. success: function(collection) {
  409. if (collection.length == 0) {
  410. var attrs = _.extend({}, collection.crmCriteria, options.defaults || {});
  411. var model = collection._prepareModel(attrs, options);
  412. options.success(model);
  413. } else if (collection.length == 1) {
  414. options.success(collection.first());
  415. } else {
  416. options.error(collection, {
  417. is_error: 1,
  418. error_message: 'Too many matches'
  419. });
  420. }
  421. },
  422. error: function(collection, errorData) {
  423. if (options.error) {
  424. options.error(collection, errorData);
  425. }
  426. }
  427. });
  428. };
  429. CRM.Backbone.Model = Backbone.Model.extend({
  430. /**
  431. * Return JSON version of model -- but only include fields that are
  432. * listed in the 'schema'.
  433. *
  434. * @return {*}
  435. */
  436. toStrictJSON: function() {
  437. var schema = this.schema;
  438. var result = this.toJSON();
  439. _.each(result, function(value, key) {
  440. if (!schema[key]) {
  441. delete result[key];
  442. }
  443. });
  444. return result;
  445. },
  446. setRel: function(key, value, options) {
  447. this.rels = this.rels || {};
  448. if (this.rels[key] != value) {
  449. this.rels[key] = value;
  450. this.trigger("rel:" + key, value);
  451. }
  452. },
  453. getRel: function(key) {
  454. return this.rels ? this.rels[key] : null;
  455. }
  456. });
  457. CRM.Backbone.Collection = Backbone.Collection.extend({
  458. /**
  459. * Store 'key' on this.rel and automatically copy it to
  460. * any children.
  461. *
  462. * @param key
  463. * @param value
  464. * @param initialModels
  465. */
  466. initializeCopyToChildrenRelation: function(key, value, initialModels) {
  467. this.setRel(key, value, {silent: true});
  468. this.on('reset', this._copyToChildren, this);
  469. this.on('add', this._copyToChild, this);
  470. },
  471. _copyToChildren: function() {
  472. var collection = this;
  473. collection.each(function(model) {
  474. collection._copyToChild(model);
  475. });
  476. },
  477. _copyToChild: function(model) {
  478. _.each(this.rels, function(relValue, relKey) {
  479. model.setRel(relKey, relValue, {silent: true});
  480. });
  481. },
  482. setRel: function(key, value, options) {
  483. this.rels = this.rels || {};
  484. if (this.rels[key] != value) {
  485. this.rels[key] = value;
  486. this.trigger("rel:" + key, value);
  487. }
  488. },
  489. getRel: function(key) {
  490. return this.rels ? this.rels[key] : null;
  491. }
  492. });
  493. /*
  494. CRM.Backbone.Form = Backbone.Form.extend({
  495. validate: function() {
  496. // Add support for form-level validators
  497. var errors = Backbone.Form.prototype.validate.apply(this, []) || {};
  498. var self = this;
  499. if (this.validators) {
  500. _.each(this.validators, function(validator) {
  501. var modelErrors = validator(this.getValue());
  502. // The following if() has been copied-pasted from the parent's
  503. // handling of model-validators. They are similar in that the errors are
  504. // probably keyed by field names... but not necessarily, so we use _others
  505. // as a fallback.
  506. if (modelErrors) {
  507. var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors);
  508. //If errors are not in object form then just store on the error object
  509. if (!isDictionary) {
  510. errors._others = errors._others || [];
  511. errors._others.push(modelErrors);
  512. }
  513. //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
  514. if (isDictionary) {
  515. _.each(modelErrors, function(val, key) {
  516. //Set error on field if there isn't one already
  517. if (self.fields[key] && !errors[key]) {
  518. self.fields[key].setError(val);
  519. errors[key] = val;
  520. }
  521. else {
  522. //Otherwise add to '_others' key
  523. errors._others = errors._others || [];
  524. var tmpErr = {};
  525. tmpErr[key] = val;
  526. errors._others.push(tmpErr);
  527. }
  528. });
  529. }
  530. }
  531. });
  532. }
  533. return _.isEmpty(errors) ? null : errors;
  534. }
  535. });
  536. */
  537. // Wrap an optional error callback with a fallback error event.
  538. var wrapError = function (model, options) {
  539. var error = options.error;
  540. options.error = function(resp) {
  541. if (error) error(model, resp, optio)
  542. model.trigger('error', model, resp, options);
  543. };
  544. };
  545. })(CRM.$, CRM._);