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