Version 1
[yaffs-website] / web / core / modules / quickedit / js / models / EntityModel.js
1 /**
2  * @file
3  * A Backbone Model for the state of an in-place editable entity in the DOM.
4  */
5
6 (function (_, $, Backbone, Drupal) {
7
8   'use strict';
9
10   Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.EntityModel# */{
11
12     /**
13      * @type {object}
14      */
15     defaults: /** @lends Drupal.quickedit.EntityModel# */{
16
17       /**
18        * The DOM element that represents this entity.
19        *
20        * It may seem bizarre to have a DOM element in a Backbone Model, but we
21        * need to be able to map entities in the DOM to EntityModels in memory.
22        *
23        * @type {HTMLElement}
24        */
25       el: null,
26
27       /**
28        * An entity ID, of the form `<entity type>/<entity ID>`
29        *
30        * @example
31        * "node/1"
32        *
33        * @type {string}
34        */
35       entityID: null,
36
37       /**
38        * An entity instance ID.
39        *
40        * The first instance of a specific entity (i.e. with a given entity ID)
41        * is assigned 0, the second 1, and so on.
42        *
43        * @type {number}
44        */
45       entityInstanceID: null,
46
47       /**
48        * The unique ID of this entity instance on the page, of the form
49        * `<entity type>/<entity ID>[entity instance ID]`
50        *
51        * @example
52        * "node/1[0]"
53        *
54        * @type {string}
55        */
56       id: null,
57
58       /**
59        * The label of the entity.
60        *
61        * @type {string}
62        */
63       label: null,
64
65       /**
66        * A FieldCollection for all fields of the entity.
67        *
68        * @type {Drupal.quickedit.FieldCollection}
69        *
70        * @see Drupal.quickedit.FieldCollection
71        */
72       fields: null,
73
74       // The attributes below are stateful. The ones above will never change
75       // during the life of a EntityModel instance.
76
77       /**
78        * Indicates whether this entity is currently being edited in-place.
79        *
80        * @type {bool}
81        */
82       isActive: false,
83
84       /**
85        * Whether one or more fields are already been stored in PrivateTempStore.
86        *
87        * @type {bool}
88        */
89       inTempStore: false,
90
91       /**
92        * Indicates whether a "Save" button is necessary or not.
93        *
94        * Whether one or more fields have already been stored in PrivateTempStore
95        * *or* the field that's currently being edited is in the 'changed' or a
96        * later state.
97        *
98        * @type {bool}
99        */
100       isDirty: false,
101
102       /**
103        * Whether the request to the server has been made to commit this entity.
104        *
105        * Used to prevent multiple such requests.
106        *
107        * @type {bool}
108        */
109       isCommitting: false,
110
111       /**
112        * The current processing state of an entity.
113        *
114        * @type {string}
115        */
116       state: 'closed',
117
118       /**
119        * IDs of fields whose new values have been stored in PrivateTempStore.
120        *
121        * We must store this on the EntityModel as well (even though it already
122        * is on the FieldModel) because when a field is rerendered, its
123        * FieldModel is destroyed and this allows us to transition it back to
124        * the proper state.
125        *
126        * @type {Array.<string>}
127        */
128       fieldsInTempStore: [],
129
130       /**
131        * A flag the tells the application that this EntityModel must be reloaded
132        * in order to restore the original values to its fields in the client.
133        *
134        * @type {bool}
135        */
136       reload: false
137     },
138
139     /**
140      * @constructs
141      *
142      * @augments Drupal.quickedit.BaseModel
143      */
144     initialize: function () {
145       this.set('fields', new Drupal.quickedit.FieldCollection());
146
147       // Respond to entity state changes.
148       this.listenTo(this, 'change:state', this.stateChange);
149
150       // The state of the entity is largely dependent on the state of its
151       // fields.
152       this.listenTo(this.get('fields'), 'change:state', this.fieldStateChange);
153
154       // Call Drupal.quickedit.BaseModel's initialize() method.
155       Drupal.quickedit.BaseModel.prototype.initialize.call(this);
156     },
157
158     /**
159      * Updates FieldModels' states when an EntityModel change occurs.
160      *
161      * @param {Drupal.quickedit.EntityModel} entityModel
162      *   The entity model
163      * @param {string} state
164      *   The state of the associated entity. One of
165      *   {@link Drupal.quickedit.EntityModel.states}.
166      * @param {object} options
167      *   Options for the entity model.
168      */
169     stateChange: function (entityModel, state, options) {
170       var to = state;
171       switch (to) {
172         case 'closed':
173           this.set({
174             isActive: false,
175             inTempStore: false,
176             isDirty: false
177           });
178           break;
179
180         case 'launching':
181           break;
182
183         case 'opening':
184           // Set the fields to candidate state.
185           entityModel.get('fields').each(function (fieldModel) {
186             fieldModel.set('state', 'candidate', options);
187           });
188           break;
189
190         case 'opened':
191           // The entity is now ready for editing!
192           this.set('isActive', true);
193           break;
194
195         case 'committing':
196           // The user indicated they want to save the entity.
197           var fields = this.get('fields');
198           // For fields that are in an active state, transition them to
199           // candidate.
200           fields.chain()
201             .filter(function (fieldModel) {
202               return _.intersection([fieldModel.get('state')], ['active']).length;
203             })
204             .each(function (fieldModel) {
205               fieldModel.set('state', 'candidate');
206             });
207           // For fields that are in a changed state, field values must first be
208           // stored in PrivateTempStore.
209           fields.chain()
210             .filter(function (fieldModel) {
211               return _.intersection([fieldModel.get('state')], Drupal.quickedit.app.changedFieldStates).length;
212             })
213             .each(function (fieldModel) {
214               fieldModel.set('state', 'saving');
215             });
216           break;
217
218         case 'deactivating':
219           var changedFields = this.get('fields')
220             .filter(function (fieldModel) {
221               return _.intersection([fieldModel.get('state')], ['changed', 'invalid']).length;
222             });
223           // If the entity contains unconfirmed or unsaved changes, return the
224           // entity to an opened state and ask the user if they would like to
225           // save the changes or discard the changes.
226           //   1. One of the fields is in a changed state. The changed field
227           //   might just be a change in the client or it might have been saved
228           //   to tempstore.
229           //   2. The saved flag is empty and the confirmed flag is empty. If
230           //   the entity has been saved to the server, the fields changed in
231           //   the client are irrelevant. If the changes are confirmed, then
232           //   proceed to set the fields to candidate state.
233           if ((changedFields.length || this.get('fieldsInTempStore').length) && (!options.saved && !options.confirmed)) {
234             // Cancel deactivation until the user confirms save or discard.
235             this.set('state', 'opened', {confirming: true});
236             // An action in reaction to state change must be deferred.
237             _.defer(function () {
238               Drupal.quickedit.app.confirmEntityDeactivation(entityModel);
239             });
240           }
241           else {
242             var invalidFields = this.get('fields')
243               .filter(function (fieldModel) {
244                 return _.intersection([fieldModel.get('state')], ['invalid']).length;
245               });
246             // Indicate if this EntityModel needs to be reloaded in order to
247             // restore the original values of its fields.
248             entityModel.set('reload', (this.get('fieldsInTempStore').length || invalidFields.length));
249             // Set all fields to the 'candidate' state. A changed field may have
250             // to go through confirmation first.
251             entityModel.get('fields').each(function (fieldModel) {
252               // If the field is already in the candidate state, trigger a
253               // change event so that the entityModel can move to the next state
254               // in deactivation.
255               if (_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) {
256                 fieldModel.trigger('change:state', fieldModel, fieldModel.get('state'), options);
257               }
258               else {
259                 fieldModel.set('state', 'candidate', options);
260               }
261             });
262           }
263           break;
264
265         case 'closing':
266           // Set all fields to the 'inactive' state.
267           options.reason = 'stop';
268           this.get('fields').each(function (fieldModel) {
269             fieldModel.set({
270               inTempStore: false,
271               state: 'inactive'
272             }, options);
273           });
274           break;
275       }
276     },
277
278     /**
279      * Updates a Field and Entity model's "inTempStore" when appropriate.
280      *
281      * Helper function.
282      *
283      * @param {Drupal.quickedit.EntityModel} entityModel
284      *   The model of the entity for which a field's state attribute has
285      *   changed.
286      * @param {Drupal.quickedit.FieldModel} fieldModel
287      *   The model of the field whose state attribute has changed.
288      *
289      * @see Drupal.quickedit.EntityModel#fieldStateChange
290      */
291     _updateInTempStoreAttributes: function (entityModel, fieldModel) {
292       var current = fieldModel.get('state');
293       var previous = fieldModel.previous('state');
294       var fieldsInTempStore = entityModel.get('fieldsInTempStore');
295       // If the fieldModel changed to the 'saved' state: remember that this
296       // field was saved to PrivateTempStore.
297       if (current === 'saved') {
298         // Mark the entity as saved in PrivateTempStore, so that we can pass the
299         // proper "reset PrivateTempStore" boolean value when communicating with
300         // the server.
301         entityModel.set('inTempStore', true);
302         // Mark the field as saved in PrivateTempStore, so that visual
303         // indicators signifying just that may be rendered.
304         fieldModel.set('inTempStore', true);
305         // Remember that this field is in PrivateTempStore, restore when
306         // rerendered.
307         fieldsInTempStore.push(fieldModel.get('fieldID'));
308         fieldsInTempStore = _.uniq(fieldsInTempStore);
309         entityModel.set('fieldsInTempStore', fieldsInTempStore);
310       }
311       // If the fieldModel changed to the 'candidate' state from the
312       // 'inactive' state, then this is a field for this entity that got
313       // rerendered. Restore its previous 'inTempStore' attribute value.
314       else if (current === 'candidate' && previous === 'inactive') {
315         fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0);
316       }
317     },
318
319     /**
320      * Reacts to state changes in this entity's fields.
321      *
322      * @param {Drupal.quickedit.FieldModel} fieldModel
323      *   The model of the field whose state attribute changed.
324      * @param {string} state
325      *   The state of the associated field. One of
326      *   {@link Drupal.quickedit.FieldModel.states}.
327      */
328     fieldStateChange: function (fieldModel, state) {
329       var entityModel = this;
330       var fieldState = state;
331       // Switch on the entityModel state.
332       // The EntityModel responds to FieldModel state changes as a function of
333       // its state. For example, a field switching back to 'candidate' state
334       // when its entity is in the 'opened' state has no effect on the entity.
335       // But that same switch back to 'candidate' state of a field when the
336       // entity is in the 'committing' state might allow the entity to proceed
337       // with the commit flow.
338       switch (this.get('state')) {
339         case 'closed':
340         case 'launching':
341           // It should be impossible to reach these: fields can't change state
342           // while the entity is closed or still launching.
343           break;
344
345         case 'opening':
346           // We must change the entity to the 'opened' state, but it must first
347           // be confirmed that all of its fieldModels have transitioned to the
348           // 'candidate' state.
349           // We do this here, because this is called every time a fieldModel
350           // changes state, hence each time this is called, we get closer to the
351           // goal of having all fieldModels in the 'candidate' state.
352           // A state change in reaction to another state change must be
353           // deferred.
354           _.defer(function () {
355             entityModel.set('state', 'opened', {
356               'accept-field-states': Drupal.quickedit.app.readyFieldStates
357             });
358           });
359           break;
360
361         case 'opened':
362           // Set the isDirty attribute when appropriate so that it is known when
363           // to display the "Save" button in the entity toolbar.
364           // Note that once a field has been changed, there's no way to discard
365           // that change, hence it will have to be saved into PrivateTempStore,
366           // or the in-place editing of this field will have to be stopped
367           // completely. In other words: once any field enters the 'changed'
368           // field, then for the remainder of the in-place editing session, the
369           // entity is by definition dirty.
370           if (fieldState === 'changed') {
371             entityModel.set('isDirty', true);
372           }
373           else {
374             this._updateInTempStoreAttributes(entityModel, fieldModel);
375           }
376           break;
377
378         case 'committing':
379           // If the field save returned a validation error, set the state of the
380           // entity back to 'opened'.
381           if (fieldState === 'invalid') {
382             // A state change in reaction to another state change must be
383             // deferred.
384             _.defer(function () {
385               entityModel.set('state', 'opened', {reason: 'invalid'});
386             });
387           }
388           else {
389             this._updateInTempStoreAttributes(entityModel, fieldModel);
390           }
391
392           // Attempt to save the entity. If the entity's fields are not yet all
393           // in a ready state, the save will not be processed.
394           var options = {
395             'accept-field-states': Drupal.quickedit.app.readyFieldStates
396           };
397           if (entityModel.set('isCommitting', true, options)) {
398             entityModel.save({
399               success: function () {
400                 entityModel.set({
401                   state: 'deactivating',
402                   isCommitting: false
403                 }, {saved: true});
404               },
405               error: function () {
406                 // Reset the "isCommitting" mutex.
407                 entityModel.set('isCommitting', false);
408                 // Change the state back to "opened", to allow the user to hit
409                 // the "Save" button again.
410                 entityModel.set('state', 'opened', {reason: 'networkerror'});
411                 // Show a modal to inform the user of the network error.
412                 var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', {'@entity-title': entityModel.get('label')});
413                 Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message);
414               }
415             });
416           }
417           break;
418
419         case 'deactivating':
420           // When setting the entity to 'closing', require that all fieldModels
421           // are in either the 'candidate' or 'highlighted' state.
422           // A state change in reaction to another state change must be
423           // deferred.
424           _.defer(function () {
425             entityModel.set('state', 'closing', {
426               'accept-field-states': Drupal.quickedit.app.readyFieldStates
427             });
428           });
429           break;
430
431         case 'closing':
432           // When setting the entity to 'closed', require that all fieldModels
433           // are in the 'inactive' state.
434           // A state change in reaction to another state change must be
435           // deferred.
436           _.defer(function () {
437             entityModel.set('state', 'closed', {
438               'accept-field-states': ['inactive']
439             });
440           });
441           break;
442       }
443     },
444
445     /**
446      * Fires an AJAX request to the REST save URL for an entity.
447      *
448      * @param {object} options
449      *   An object of options that contains:
450      * @param {function} [options.success]
451      *   A function to invoke if the entity is successfully saved.
452      */
453     save: function (options) {
454       var entityModel = this;
455
456       // Create a Drupal.ajax instance to save the entity.
457       var entitySaverAjax = Drupal.ajax({
458         url: Drupal.url('quickedit/entity/' + entityModel.get('entityID')),
459         error: function () {
460           // Let the Drupal.quickedit.EntityModel Backbone model's error()
461           // method handle errors.
462           options.error.call(entityModel);
463         }
464       });
465       // Entity saved successfully.
466       entitySaverAjax.commands.quickeditEntitySaved = function (ajax, response, status) {
467         // All fields have been moved from PrivateTempStore to permanent
468         // storage, update the "inTempStore" attribute on FieldModels, on the
469         // EntityModel and clear EntityModel's "fieldInTempStore" attribute.
470         entityModel.get('fields').each(function (fieldModel) {
471           fieldModel.set('inTempStore', false);
472         });
473         entityModel.set('inTempStore', false);
474         entityModel.set('fieldsInTempStore', []);
475
476         // Invoke the optional success callback.
477         if (options.success) {
478           options.success.call(entityModel);
479         }
480       };
481       // Trigger the AJAX request, which will will return the
482       // quickeditEntitySaved AJAX command to which we then react.
483       entitySaverAjax.execute();
484     },
485
486     /**
487      * Validate the entity model.
488      *
489      * @param {object} attrs
490      *   The attributes changes in the save or set call.
491      * @param {object} options
492      *   An object with the following option:
493      * @param {string} [options.reason]
494      *   A string that conveys a particular reason to allow for an exceptional
495      *   state change.
496      * @param {Array} options.accept-field-states
497      *   An array of strings that represent field states that the entities must
498      *   be in to validate. For example, if `accept-field-states` is
499      *   `['candidate', 'highlighted']`, then all the fields of the entity must
500      *   be in either of these two states for the save or set call to
501      *   validate and proceed.
502      *
503      * @return {string}
504      *   A string to say something about the state of the entity model.
505      */
506     validate: function (attrs, options) {
507       var acceptedFieldStates = options['accept-field-states'] || [];
508
509       // Validate state change.
510       var currentState = this.get('state');
511       var nextState = attrs.state;
512       if (currentState !== nextState) {
513         // Ensure it's a valid state.
514         if (_.indexOf(this.constructor.states, nextState) === -1) {
515           return '"' + nextState + '" is an invalid state';
516         }
517
518         // Ensure it's a state change that is allowed.
519         // Check if the acceptStateChange function accepts it.
520         if (!this._acceptStateChange(currentState, nextState, options)) {
521           return 'state change not accepted';
522         }
523         // If that function accepts it, then ensure all fields are also in an
524         // acceptable state.
525         else if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
526           return 'state change not accepted because fields are not in acceptable state';
527         }
528       }
529
530       // Validate setting isCommitting = true.
531       var currentIsCommitting = this.get('isCommitting');
532       var nextIsCommitting = attrs.isCommitting;
533       if (currentIsCommitting === false && nextIsCommitting === true) {
534         if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
535           return 'isCommitting change not accepted because fields are not in acceptable state';
536         }
537       }
538       else if (currentIsCommitting === true && nextIsCommitting === true) {
539         return 'isCommitting is a mutex, hence only changes are allowed';
540       }
541     },
542
543     /**
544      * Checks if a state change can be accepted.
545      *
546      * @param {string} from
547      *   From state.
548      * @param {string} to
549      *   To state.
550      * @param {object} context
551      *   Context for the check.
552      * @param {string} context.reason
553      *   The reason for the state change.
554      * @param {bool} context.confirming
555      *   Whether context is confirming or not.
556      *
557      * @return {bool}
558      *   Whether the state change is accepted or not.
559      *
560      * @see Drupal.quickedit.AppView#acceptEditorStateChange
561      */
562     _acceptStateChange: function (from, to, context) {
563       var accept = true;
564
565       // In general, enforce the states sequence. Disallow going back from a
566       // "later" state to an "earlier" state, except in explicitly allowed
567       // cases.
568       if (!this.constructor.followsStateSequence(from, to)) {
569         accept = false;
570
571         // Allow: closing -> closed.
572         // Necessary to stop editing an entity.
573         if (from === 'closing' && to === 'closed') {
574           accept = true;
575         }
576         // Allow: committing -> opened.
577         // Necessary to be able to correct an invalid field, or to hit the
578         // "Save" button again after a server/network error.
579         else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) {
580           accept = true;
581         }
582         // Allow: deactivating -> opened.
583         // Necessary to be able to confirm changes with the user.
584         else if (from === 'deactivating' && to === 'opened' && context.confirming) {
585           accept = true;
586         }
587         // Allow: opened -> deactivating.
588         // Necessary to be able to stop editing.
589         else if (from === 'opened' && to === 'deactivating' && context.confirmed) {
590           accept = true;
591         }
592       }
593
594       return accept;
595     },
596
597     /**
598      * Checks if fields have acceptable states.
599      *
600      * @param {Array} acceptedFieldStates
601      *   An array of acceptable field states to check for.
602      *
603      * @return {bool}
604      *   Whether the fields have an acceptable state.
605      *
606      * @see Drupal.quickedit.EntityModel#validate
607      */
608     _fieldsHaveAcceptableStates: function (acceptedFieldStates) {
609       var accept = true;
610
611       // If no acceptable field states are provided, assume all field states are
612       // acceptable. We want to let validation pass as a default and only
613       // check validity on calls to set that explicitly request it.
614       if (acceptedFieldStates.length > 0) {
615         var fieldStates = this.get('fields').pluck('state') || [];
616         // If not all fields are in one of the accepted field states, then we
617         // still can't allow this state change.
618         if (_.difference(fieldStates, acceptedFieldStates).length) {
619           accept = false;
620         }
621       }
622
623       return accept;
624     },
625
626     /**
627      * Destroys the entity model.
628      *
629      * @param {object} options
630      *   Options for the entity model.
631      */
632     destroy: function (options) {
633       Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
634
635       this.stopListening();
636
637       // Destroy all fields of this entity.
638       this.get('fields').reset();
639     },
640
641     /**
642      * @inheritdoc
643      */
644     sync: function () {
645       // We don't use REST updates to sync.
646       return;
647     }
648
649   }, /** @lends Drupal.quickedit.EntityModel */{
650
651     /**
652      * Sequence of all possible states an entity can be in during quickediting.
653      *
654      * @type {Array.<string>}
655      */
656     states: [
657       // Initial state, like field's 'inactive' OR the user has just finished
658       // in-place editing this entity.
659       // - Trigger: none (initial) or EntityModel (finished).
660       // - Expected behavior: (when not initial state): tear down
661       //   EntityToolbarView, in-place editors and related views.
662       'closed',
663       // User has activated in-place editing of this entity.
664       // - Trigger: user.
665       // - Expected behavior: the EntityToolbarView is gets set up, in-place
666       //   editors (EditorViews) and related views for this entity's fields are
667       //   set up. Upon completion of those, the state is changed to 'opening'.
668       'launching',
669       // Launching has finished.
670       // - Trigger: application.
671       // - Guarantees: in-place editors ready for use, all entity and field
672       //   views have been set up, all fields are in the 'inactive' state.
673       // - Expected behavior: all fields are changed to the 'candidate' state
674       //   and once this is completed, the entity state will be changed to
675       //   'opened'.
676       'opening',
677       // Opening has finished.
678       // - Trigger: EntityModel.
679       // - Guarantees: see 'opening', all fields are in the 'candidate' state.
680       // - Expected behavior: the user is able to actually use in-place editing.
681       'opened',
682       // User has clicked the 'Save' button (and has thus changed at least one
683       // field).
684       // - Trigger: user.
685       // - Guarantees: see 'opened', plus: either a changed field is in
686       //   PrivateTempStore, or the user has just modified a field without
687       //   activating (switching to) another field.
688       // - Expected behavior: 1) if any of the fields are not yet in
689       //   PrivateTempStore, save them to PrivateTempStore, 2) if then any of
690       //   the fields has the 'invalid' state, then change the entity state back
691       //   to 'opened', otherwise: save the entity by committing it from
692       //   PrivateTempStore into permanent storage.
693       'committing',
694       // User has clicked the 'Close' button, or has clicked the 'Save' button
695       // and that was successfully completed.
696       // - Trigger: user or EntityModel.
697       // - Guarantees: when having clicked 'Close' hardly any: fields may be in
698       //   a variety of states; when having clicked 'Save': all fields are in
699       //   the 'candidate' state.
700       // - Expected behavior: transition all fields to the 'candidate' state,
701       //   possibly requiring confirmation in the case of having clicked
702       //   'Close'.
703       'deactivating',
704       // Deactivation has been completed.
705       // - Trigger: EntityModel.
706       // - Guarantees: all fields are in the 'candidate' state.
707       // - Expected behavior: change all fields to the 'inactive' state.
708       'closing'
709     ],
710
711     /**
712      * Indicates whether the 'from' state comes before the 'to' state.
713      *
714      * @param {string} from
715      *   One of {@link Drupal.quickedit.EntityModel.states}.
716      * @param {string} to
717      *   One of {@link Drupal.quickedit.EntityModel.states}.
718      *
719      * @return {bool}
720      *   Whether the 'from' state comes before the 'to' state.
721      */
722     followsStateSequence: function (from, to) {
723       return _.indexOf(this.states, from) < _.indexOf(this.states, to);
724     }
725
726   });
727
728   /**
729    * @constructor
730    *
731    * @augments Backbone.Collection
732    */
733   Drupal.quickedit.EntityCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.EntityCollection# */{
734
735     /**
736      * @type {Drupal.quickedit.EntityModel}
737      */
738     model: Drupal.quickedit.EntityModel
739   });
740
741 }(_, jQuery, Backbone, Drupal));