comparison core/modules/quickedit/js/models/EntityModel.es6.js @ 0:4c8ae668cc8c

Initial import (non-working)
author Chris Cannam
date Wed, 29 Nov 2017 16:09:58 +0000
parents
children 1fec387a4317
comparison
equal deleted inserted replaced
-1:000000000000 0:4c8ae668cc8c
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));