Security update for Core, with self-updated composer
[yaffs-website] / web / core / modules / quickedit / js / views / AppView.es6.js
1 /**
2  * @file
3  * A Backbone View that controls the overall "in-place editing application".
4  *
5  * @see Drupal.quickedit.AppModel
6  */
7
8 (function ($, _, Backbone, Drupal) {
9   // Indicates whether the page should be reloaded after in-place editing has
10   // shut down. A page reload is necessary to re-instate the original HTML of
11   // the edited fields if in-place editing has been canceled and one or more of
12   // the entity's fields were saved to PrivateTempStore: one of them may have
13   // been changed to the empty value and hence may have been rerendered as the
14   // empty string, which makes it impossible for Quick Edit to know where to
15   // restore the original HTML.
16   let reload = false;
17
18   Drupal.quickedit.AppView = Backbone.View.extend(/** @lends Drupal.quickedit.AppView# */{
19
20     /**
21      * @constructs
22      *
23      * @augments Backbone.View
24      *
25      * @param {object} options
26      *   An object with the following keys:
27      * @param {Drupal.quickedit.AppModel} options.model
28      *   The application state model.
29      * @param {Drupal.quickedit.EntityCollection} options.entitiesCollection
30      *   All on-page entities.
31      * @param {Drupal.quickedit.FieldCollection} options.fieldsCollection
32      *   All on-page fields
33      */
34     initialize(options) {
35       // AppView's configuration for handling states.
36       // @see Drupal.quickedit.FieldModel.states
37       this.activeFieldStates = ['activating', 'active'];
38       this.singleFieldStates = ['highlighted', 'activating', 'active'];
39       this.changedFieldStates = ['changed', 'saving', 'saved', 'invalid'];
40       this.readyFieldStates = ['candidate', 'highlighted'];
41
42       // Track app state.
43       this.listenTo(options.entitiesCollection, 'change:state', this.appStateChange);
44       this.listenTo(options.entitiesCollection, 'change:isActive', this.enforceSingleActiveEntity);
45
46       // Track app state.
47       this.listenTo(options.fieldsCollection, 'change:state', this.editorStateChange);
48       // Respond to field model HTML representation change events.
49       this.listenTo(options.fieldsCollection, 'change:html', this.renderUpdatedField);
50       this.listenTo(options.fieldsCollection, 'change:html', this.propagateUpdatedField);
51       // Respond to addition.
52       this.listenTo(options.fieldsCollection, 'add', this.rerenderedFieldToCandidate);
53       // Respond to destruction.
54       this.listenTo(options.fieldsCollection, 'destroy', this.teardownEditor);
55     },
56
57     /**
58      * Handles setup/teardown and state changes when the active entity changes.
59      *
60      * @param {Drupal.quickedit.EntityModel} entityModel
61      *   An instance of the EntityModel class.
62      * @param {string} state
63      *   The state of the associated field. One of
64      *   {@link Drupal.quickedit.EntityModel.states}.
65      */
66     appStateChange(entityModel, state) {
67       const app = this;
68       let entityToolbarView;
69       switch (state) {
70         case 'launching':
71           reload = false;
72           // First, create an entity toolbar view.
73           entityToolbarView = new Drupal.quickedit.EntityToolbarView({
74             model: entityModel,
75             appModel: this.model,
76           });
77           entityModel.toolbarView = entityToolbarView;
78           // Second, set up in-place editors.
79           // They must be notified of state changes, hence this must happen
80           // while the associated fields are still in the 'inactive' state.
81           entityModel.get('fields').each((fieldModel) => {
82             app.setupEditor(fieldModel);
83           });
84           // Third, transition the entity to the 'opening' state, which will
85           // transition all fields from 'inactive' to 'candidate'.
86           _.defer(() => {
87             entityModel.set('state', 'opening');
88           });
89           break;
90
91         case 'closed':
92           entityToolbarView = entityModel.toolbarView;
93           // First, tear down the in-place editors.
94           entityModel.get('fields').each((fieldModel) => {
95             app.teardownEditor(fieldModel);
96           });
97           // Second, tear down the entity toolbar view.
98           if (entityToolbarView) {
99             entityToolbarView.remove();
100             delete entityModel.toolbarView;
101           }
102           // A page reload may be necessary to re-instate the original HTML of
103           // the edited fields.
104           if (reload) {
105             reload = false;
106             location.reload();
107           }
108           break;
109       }
110     },
111
112     /**
113      * Accepts or reject editor (Editor) state changes.
114      *
115      * This is what ensures that the app is in control of what happens.
116      *
117      * @param {string} from
118      *   The previous state.
119      * @param {string} to
120      *   The new state.
121      * @param {null|object} context
122      *   The context that is trying to trigger the state change.
123      * @param {Drupal.quickedit.FieldModel} fieldModel
124      *   The fieldModel to which this change applies.
125      *
126      * @return {bool}
127      *   Whether the editor change was accepted or rejected.
128      */
129     acceptEditorStateChange(from, to, context, fieldModel) {
130       let accept = true;
131
132       // If the app is in view mode, then reject all state changes except for
133       // those to 'inactive'.
134       if (context && (context.reason === 'stop' || context.reason === 'rerender')) {
135         if (from === 'candidate' && to === 'inactive') {
136           accept = true;
137         }
138       }
139       // Handling of edit mode state changes is more granular.
140       else {
141         // In general, enforce the states sequence. Disallow going back from a
142         // "later" state to an "earlier" state, except in explicitly allowed
143         // cases.
144         if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) {
145           accept = false;
146           // Allow: activating/active -> candidate.
147           // Necessary to stop editing a field.
148           if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') {
149             accept = true;
150           }
151           // Allow: changed/invalid -> candidate.
152           // Necessary to stop editing a field when it is changed or invalid.
153           else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
154             accept = true;
155           }
156           // Allow: highlighted -> candidate.
157           // Necessary to stop highlighting a field.
158           else if (from === 'highlighted' && to === 'candidate') {
159             accept = true;
160           }
161           // Allow: saved -> candidate.
162           // Necessary when successfully saved a field.
163           else if (from === 'saved' && to === 'candidate') {
164             accept = true;
165           }
166           // Allow: invalid -> saving.
167           // Necessary to be able to save a corrected, invalid field.
168           else if (from === 'invalid' && to === 'saving') {
169             accept = true;
170           }
171           // Allow: invalid -> activating.
172           // Necessary to be able to correct a field that turned out to be
173           // invalid after the user already had moved on to the next field
174           // (which we explicitly allow to have a fluent UX).
175           else if (from === 'invalid' && to === 'activating') {
176             accept = true;
177           }
178         }
179
180         // If it's not against the general principle, then here are more
181         // disallowed cases to check.
182         if (accept) {
183           let activeField;
184           let activeFieldState;
185           // Ensure only one field (editor) at a time is active â€¦ but allow a
186           // user to hop from one field to the next, even if we still have to
187           // start saving the field that is currently active: assume it will be
188           // valid, to allow for a fluent UX. (If it turns out to be invalid,
189           // this block of code also handles that.)
190           if ((this.readyFieldStates.indexOf(from) !== -1 || from === 'invalid') && this.activeFieldStates.indexOf(to) !== -1) {
191             activeField = this.model.get('activeField');
192             if (activeField && activeField !== fieldModel) {
193               activeFieldState = activeField.get('state');
194               // Allow the state change. If the state of the active field is:
195               // - 'activating' or 'active': change it to 'candidate'
196               // - 'changed' or 'invalid': change it to 'saving'
197               // - 'saving' or 'saved': don't do anything.
198               if (this.activeFieldStates.indexOf(activeFieldState) !== -1) {
199                 activeField.set('state', 'candidate');
200               }
201               else if (activeFieldState === 'changed' || activeFieldState === 'invalid') {
202                 activeField.set('state', 'saving');
203               }
204
205               // If the field that's being activated is in fact already in the
206               // invalid state (which can only happen because above we allowed
207               // the user to move on to another field to allow for a fluent UX;
208               // we assumed it would be saved successfully), then we shouldn't
209               // allow the field to enter the 'activating' state, instead, we
210               // simply change the active editor. All guarantees and
211               // assumptions for this field still hold!
212               if (from === 'invalid') {
213                 this.model.set('activeField', fieldModel);
214                 accept = false;
215               }
216               // Do not reject: the field is either in the 'candidate' or
217               // 'highlighted' state and we allow it to enter the 'activating'
218               // state!
219             }
220           }
221           // Reject going from activating/active to candidate because of a
222           // mouseleave.
223           else if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') {
224             if (context && context.reason === 'mouseleave') {
225               accept = false;
226             }
227           }
228           // When attempting to stop editing a changed/invalid property, ask for
229           // confirmation.
230           else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
231             if (context && context.reason === 'mouseleave') {
232               accept = false;
233             }
234             else {
235               // Check whether the transition has been confirmed?
236               if (context && context.confirmed) {
237                 accept = true;
238               }
239             }
240           }
241         }
242       }
243
244       return accept;
245     },
246
247     /**
248      * Sets up the in-place editor for the given field.
249      *
250      * Must happen before the fieldModel's state is changed to 'candidate'.
251      *
252      * @param {Drupal.quickedit.FieldModel} fieldModel
253      *   The field for which an in-place editor must be set up.
254      */
255     setupEditor(fieldModel) {
256       // Get the corresponding entity toolbar.
257       const entityModel = fieldModel.get('entity');
258       const entityToolbarView = entityModel.toolbarView;
259       // Get the field toolbar DOM root from the entity toolbar.
260       const fieldToolbarRoot = entityToolbarView.getToolbarRoot();
261       // Create in-place editor.
262       const editorName = fieldModel.get('metadata').editor;
263       const editorModel = new Drupal.quickedit.EditorModel();
264       const editorView = new Drupal.quickedit.editors[editorName]({
265         el: $(fieldModel.get('el')),
266         model: editorModel,
267         fieldModel,
268       });
269
270       // Create in-place editor's toolbar for this field â€” stored inside the
271       // entity toolbar, the entity toolbar will position itself appropriately
272       // above (or below) the edited element.
273       const toolbarView = new Drupal.quickedit.FieldToolbarView({
274         el: fieldToolbarRoot,
275         model: fieldModel,
276         $editedElement: $(editorView.getEditedElement()),
277         editorView,
278         entityModel,
279       });
280
281       // Create decoration for edited element: padding if necessary, sets
282       // classes on the element to style it according to the current state.
283       const decorationView = new Drupal.quickedit.FieldDecorationView({
284         el: $(editorView.getEditedElement()),
285         model: fieldModel,
286         editorView,
287       });
288
289       // Track these three views in FieldModel so that we can tear them down
290       // correctly.
291       fieldModel.editorView = editorView;
292       fieldModel.toolbarView = toolbarView;
293       fieldModel.decorationView = decorationView;
294     },
295
296     /**
297      * Tears down the in-place editor for the given field.
298      *
299      * Must happen after the fieldModel's state is changed to 'inactive'.
300      *
301      * @param {Drupal.quickedit.FieldModel} fieldModel
302      *   The field for which an in-place editor must be torn down.
303      */
304     teardownEditor(fieldModel) {
305       // Early-return if this field was not yet decorated.
306       if (typeof fieldModel.editorView === 'undefined') {
307         return;
308       }
309
310       // Unbind event handlers; remove toolbar element; delete toolbar view.
311       fieldModel.toolbarView.remove();
312       delete fieldModel.toolbarView;
313
314       // Unbind event handlers; delete decoration view. Don't remove the element
315       // because that would remove the field itself.
316       fieldModel.decorationView.remove();
317       delete fieldModel.decorationView;
318
319       // Unbind event handlers; delete editor view. Don't remove the element
320       // because that would remove the field itself.
321       fieldModel.editorView.remove();
322       delete fieldModel.editorView;
323     },
324
325     /**
326      * Asks the user to confirm whether he wants to stop editing via a modal.
327      *
328      * @param {Drupal.quickedit.EntityModel} entityModel
329      *   An instance of the EntityModel class.
330      *
331      * @see Drupal.quickedit.AppView#acceptEditorStateChange
332      */
333     confirmEntityDeactivation(entityModel) {
334       const that = this;
335       let discardDialog;
336
337       function closeDiscardDialog(action) {
338         discardDialog.close(action);
339         // The active modal has been removed.
340         that.model.set('activeModal', null);
341
342         // If the targetState is saving, the field must be saved, then the
343         // entity must be saved.
344         if (action === 'save') {
345           entityModel.set('state', 'committing', { confirmed: true });
346         }
347         else {
348           entityModel.set('state', 'deactivating', { confirmed: true });
349           // Editing has been canceled and the changes will not be saved. Mark
350           // the page for reload if the entityModel declares that it requires
351           // a reload.
352           if (entityModel.get('reload')) {
353             reload = true;
354             entityModel.set('reload', false);
355           }
356         }
357       }
358
359       // Only instantiate if there isn't a modal instance visible yet.
360       if (!this.model.get('activeModal')) {
361         const $unsavedChanges = $(`<div>${Drupal.t('You have unsaved changes')}</div>`);
362         discardDialog = Drupal.dialog($unsavedChanges.get(0), {
363           title: Drupal.t('Discard changes?'),
364           dialogClass: 'quickedit-discard-modal',
365           resizable: false,
366           buttons: [
367             {
368               text: Drupal.t('Save'),
369               click() {
370                 closeDiscardDialog('save');
371               },
372               primary: true,
373             },
374             {
375               text: Drupal.t('Discard changes'),
376               click() {
377                 closeDiscardDialog('discard');
378               },
379             },
380           ],
381           // Prevent this modal from being closed without the user making a
382           // choice as per http://stackoverflow.com/a/5438771.
383           closeOnEscape: false,
384           create() {
385             $(this).parent().find('.ui-dialog-titlebar-close').remove();
386           },
387           beforeClose: false,
388           close(event) {
389             // Automatically destroy the DOM element that was used for the
390             // dialog.
391             $(event.target).remove();
392           },
393         });
394         this.model.set('activeModal', discardDialog);
395
396         discardDialog.showModal();
397       }
398     },
399
400     /**
401      * Reacts to field state changes; tracks global state.
402      *
403      * @param {Drupal.quickedit.FieldModel} fieldModel
404      *   The `fieldModel` holding the state.
405      * @param {string} state
406      *   The state of the associated field. One of
407      *   {@link Drupal.quickedit.FieldModel.states}.
408      */
409     editorStateChange(fieldModel, state) {
410       const from = fieldModel.previous('state');
411       const to = state;
412
413       // Keep track of the highlighted field in the global state.
414       if (_.indexOf(this.singleFieldStates, to) !== -1 && this.model.get('highlightedField') !== fieldModel) {
415         this.model.set('highlightedField', fieldModel);
416       }
417       else if (this.model.get('highlightedField') === fieldModel && to === 'candidate') {
418         this.model.set('highlightedField', null);
419       }
420
421       // Keep track of the active field in the global state.
422       if (_.indexOf(this.activeFieldStates, to) !== -1 && this.model.get('activeField') !== fieldModel) {
423         this.model.set('activeField', fieldModel);
424       }
425       else if (this.model.get('activeField') === fieldModel && to === 'candidate') {
426         // Discarded if it transitions from a changed state to 'candidate'.
427         if (from === 'changed' || from === 'invalid') {
428           fieldModel.editorView.revert();
429         }
430         this.model.set('activeField', null);
431       }
432     },
433
434     /**
435      * Render an updated field (a field whose 'html' attribute changed).
436      *
437      * @param {Drupal.quickedit.FieldModel} fieldModel
438      *   The FieldModel whose 'html' attribute changed.
439      * @param {string} html
440      *   The updated 'html' attribute.
441      * @param {object} options
442      *   An object with the following keys:
443      * @param {bool} options.propagation
444      *   Whether this change to the 'html' attribute occurred because of the
445      *   propagation of changes to another instance of this field.
446      */
447     renderUpdatedField(fieldModel, html, options) {
448       // Get data necessary to rerender property before it is unavailable.
449       const $fieldWrapper = $(fieldModel.get('el'));
450       const $context = $fieldWrapper.parent();
451
452       const renderField = function () {
453         // Destroy the field model; this will cause all attached views to be
454         // destroyed too, and removal from all collections in which it exists.
455         fieldModel.destroy();
456
457         // Replace the old content with the new content.
458         $fieldWrapper.replaceWith(html);
459
460         // Attach behaviors again to the modified piece of HTML; this will
461         // create a new field model and call rerenderedFieldToCandidate() with
462         // it.
463         Drupal.attachBehaviors($context.get(0));
464       };
465
466       // When propagating the changes of another instance of this field, this
467       // field is not being actively edited and hence no state changes are
468       // necessary. So: only update the state of this field when the rerendering
469       // of this field happens not because of propagation, but because it is
470       // being edited itself.
471       if (!options.propagation) {
472         // Deferred because renderUpdatedField is reacting to a field model
473         // change event, and we want to make sure that event fully propagates
474         // before making another change to the same model.
475         _.defer(() => {
476           // First set the state to 'candidate', to allow all attached views to
477           // clean up all their "active state"-related changes.
478           fieldModel.set('state', 'candidate');
479
480           // Similarly, the above .set() call's change event must fully
481           // propagate before calling it again.
482           _.defer(() => {
483             // Set the field's state to 'inactive', to enable the updating of
484             // its DOM value.
485             fieldModel.set('state', 'inactive', { reason: 'rerender' });
486
487             renderField();
488           });
489         });
490       }
491       else {
492         renderField();
493       }
494     },
495
496     /**
497      * Propagates changes to an updated field to all instances of that field.
498      *
499      * @param {Drupal.quickedit.FieldModel} updatedField
500      *   The FieldModel whose 'html' attribute changed.
501      * @param {string} html
502      *   The updated 'html' attribute.
503      * @param {object} options
504      *   An object with the following keys:
505      * @param {bool} options.propagation
506      *   Whether this change to the 'html' attribute occurred because of the
507      *   propagation of changes to another instance of this field.
508      *
509      * @see Drupal.quickedit.AppView#renderUpdatedField
510      */
511     propagateUpdatedField(updatedField, html, options) {
512       // Don't propagate field updates that themselves were caused by
513       // propagation.
514       if (options.propagation) {
515         return;
516       }
517
518       const htmlForOtherViewModes = updatedField.get('htmlForOtherViewModes');
519       Drupal.quickedit.collections.fields
520         // Find all instances of fields that display the same logical field
521         // (same entity, same field, just a different instance and maybe a
522         // different view mode).
523         .where({ logicalFieldID: updatedField.get('logicalFieldID') })
524         .forEach((field) => {
525           // Ignore the field that was already updated.
526           if (field === updatedField) {
527
528           }
529           // If this other instance of the field has the same view mode, we can
530           // update it easily.
531           else if (field.getViewMode() === updatedField.getViewMode()) {
532             field.set('html', updatedField.get('html'));
533           }
534           // If this other instance of the field has a different view mode, and
535           // that is one of the view modes for which a re-rendered version is
536           // available (and that should be the case unless this field was only
537           // added to the page after editing of the updated field began), then
538           // use that view mode's re-rendered version.
539           else if (field.getViewMode() in htmlForOtherViewModes) {
540             field.set('html', htmlForOtherViewModes[field.getViewMode()], { propagation: true });
541           }
542         });
543     },
544
545     /**
546      * If the new in-place editable field is for the entity that's currently
547      * being edited, then transition it to the 'candidate' state.
548      *
549      * This happens when a field was modified, saved and hence rerendered.
550      *
551      * @param {Drupal.quickedit.FieldModel} fieldModel
552      *   A field that was just added to the collection of fields.
553      */
554     rerenderedFieldToCandidate(fieldModel) {
555       const activeEntity = Drupal.quickedit.collections.entities.findWhere({ isActive: true });
556
557       // Early-return if there is no active entity.
558       if (!activeEntity) {
559         return;
560       }
561
562       // If the field's entity is the active entity, make it a candidate.
563       if (fieldModel.get('entity') === activeEntity) {
564         this.setupEditor(fieldModel);
565         fieldModel.set('state', 'candidate');
566       }
567     },
568
569     /**
570      * EntityModel Collection change handler.
571      *
572      * Handler is called `change:isActive` and enforces a single active entity.
573      *
574      * @param {Drupal.quickedit.EntityModel} changedEntityModel
575      *   The entityModel instance whose active state has changed.
576      */
577     enforceSingleActiveEntity(changedEntityModel) {
578       // When an entity is deactivated, we don't need to enforce anything.
579       if (changedEntityModel.get('isActive') === false) {
580         return;
581       }
582
583       // This entity was activated; deactivate all other entities.
584       changedEntityModel.collection.chain()
585         .filter(entityModel => entityModel.get('isActive') === true && entityModel !== changedEntityModel)
586         .each((entityModel) => {
587           entityModel.set('state', 'deactivating');
588         });
589     },
590
591   });
592 }(jQuery, _, Backbone, Drupal));