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