PageRenderTime 58ms CodeModel.GetById 24ms RepoModel.GetById 0ms app.codeStats 1ms

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

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