Security update for Core, with self-updated composer
[yaffs-website] / web / core / modules / quickedit / js / views / EntityToolbarView.es6.js
1 /**
2  * @file
3  * A Backbone View that provides an entity level toolbar.
4  */
5
6 (function ($, _, Backbone, Drupal, debounce) {
7   Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{
8
9     /**
10      * @type {jQuery}
11      */
12     _fieldToolbarRoot: null,
13
14     /**
15      * @return {object}
16      *   A map of events.
17      */
18     events() {
19       const map = {
20         'click button.action-save': 'onClickSave',
21         'click button.action-cancel': 'onClickCancel',
22         mouseenter: 'onMouseenter',
23       };
24       return map;
25     },
26
27     /**
28      * @constructs
29      *
30      * @augments Backbone.View
31      *
32      * @param {object} options
33      *   Options to construct the view.
34      * @param {Drupal.quickedit.AppModel} options.appModel
35      *   A quickedit `AppModel` to use in the view.
36      */
37     initialize(options) {
38       const that = this;
39       this.appModel = options.appModel;
40       this.$entity = $(this.model.get('el'));
41
42       // Rerender whenever the entity state changes.
43       this.listenTo(this.model, 'change:isActive change:isDirty change:state', this.render);
44       // Also rerender whenever a different field is highlighted or activated.
45       this.listenTo(this.appModel, 'change:highlightedField change:activeField', this.render);
46       // Rerender when a field of the entity changes state.
47       this.listenTo(this.model.get('fields'), 'change:state', this.fieldStateChange);
48
49       // Reposition the entity toolbar as the viewport and the position within
50       // the viewport changes.
51       $(window).on('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit', debounce($.proxy(this.windowChangeHandler, this), 150));
52
53       // Adjust the fence placement within which the entity toolbar may be
54       // positioned.
55       $(document).on('drupalViewportOffsetChange.quickedit', (event, offsets) => {
56         if (that.$fence) {
57           that.$fence.css(offsets);
58         }
59       });
60
61       // Set the entity toolbar DOM element as the el for this view.
62       const $toolbar = this.buildToolbarEl();
63       this.setElement($toolbar);
64       this._fieldToolbarRoot = $toolbar.find('.quickedit-toolbar-field').get(0);
65
66       // Initial render.
67       this.render();
68     },
69
70     /**
71      * @inheritdoc
72      *
73      * @return {Drupal.quickedit.EntityToolbarView}
74      *   The entity toolbar view.
75      */
76     render() {
77       if (this.model.get('isActive')) {
78         // If the toolbar container doesn't exist, create it.
79         const $body = $('body');
80         if ($body.children('#quickedit-entity-toolbar').length === 0) {
81           $body.append(this.$el);
82         }
83         // The fence will define a area on the screen that the entity toolbar
84         // will be position within.
85         if ($body.children('#quickedit-toolbar-fence').length === 0) {
86           this.$fence = $(Drupal.theme('quickeditEntityToolbarFence'))
87             .css(Drupal.displace())
88             .appendTo($body);
89         }
90         // Adds the entity title to the toolbar.
91         this.label();
92
93         // Show the save and cancel buttons.
94         this.show('ops');
95         // If render is being called and the toolbar is already visible, just
96         // reposition it.
97         this.position();
98       }
99
100       // The save button text and state varies with the state of the entity
101       // model.
102       const $button = this.$el.find('.quickedit-button.action-save');
103       const isDirty = this.model.get('isDirty');
104       // Adjust the save button according to the state of the model.
105       switch (this.model.get('state')) {
106         // Quick editing is active, but no field is being edited.
107         case 'opened':
108           // The saving throbber is not managed by AJAX system. The
109           // EntityToolbarView manages this visual element.
110           $button
111             .removeClass('action-saving icon-throbber icon-end')
112             .text(Drupal.t('Save'))
113             .removeAttr('disabled')
114             .attr('aria-hidden', !isDirty);
115           break;
116
117         // The changes to the fields of the entity are being committed.
118         case 'committing':
119           $button
120             .addClass('action-saving icon-throbber icon-end')
121             .text(Drupal.t('Saving'))
122             .attr('disabled', 'disabled');
123           break;
124
125         default:
126           $button.attr('aria-hidden', true);
127           break;
128       }
129
130       return this;
131     },
132
133     /**
134      * @inheritdoc
135      */
136     remove() {
137       // Remove additional DOM elements controlled by this View.
138       this.$fence.remove();
139
140       // Stop listening to additional events.
141       $(window).off('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit');
142       $(document).off('drupalViewportOffsetChange.quickedit');
143
144       Backbone.View.prototype.remove.call(this);
145     },
146
147     /**
148      * Repositions the entity toolbar on window scroll and resize.
149      *
150      * @param {jQuery.Event} event
151      *   The scroll or resize event.
152      */
153     windowChangeHandler(event) {
154       this.position();
155     },
156
157     /**
158      * Determines the actions to take given a change of state.
159      *
160      * @param {Drupal.quickedit.FieldModel} model
161      *   The `FieldModel` model.
162      * @param {string} state
163      *   The state of the associated field. One of
164      *   {@link Drupal.quickedit.FieldModel.states}.
165      */
166     fieldStateChange(model, state) {
167       switch (state) {
168         case 'active':
169           this.render();
170           break;
171
172         case 'invalid':
173           this.render();
174           break;
175       }
176     },
177
178     /**
179      * Uses the jQuery.ui.position() method to position the entity toolbar.
180      *
181      * @param {HTMLElement} [element]
182      *   The element against which the entity toolbar is positioned.
183      */
184     position(element) {
185       clearTimeout(this.timer);
186
187       const that = this;
188       // Vary the edge of the positioning according to the direction of language
189       // in the document.
190       const edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left';
191       // A time unit to wait until the entity toolbar is repositioned.
192       let delay = 0;
193       // Determines what check in the series of checks below should be
194       // evaluated.
195       let check = 0;
196       // When positioned against an active field that has padding, we should
197       // ignore that padding when positioning the toolbar, to not unnecessarily
198       // move the toolbar horizontally, which feels annoying.
199       let horizontalPadding = 0;
200       let of;
201       let activeField;
202       let highlightedField;
203       // There are several elements in the page that the entity toolbar might be
204       // positioned against. They are considered below in a priority order.
205       do {
206         switch (check) {
207           case 0:
208             // Position against a specific element.
209             of = element;
210             break;
211
212           case 1:
213             // Position against a form container.
214             activeField = Drupal.quickedit.app.model.get('activeField');
215             of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.quickedit-form');
216             break;
217
218           case 2:
219             // Position against an active field.
220             of = activeField && activeField.editorView && activeField.editorView.getEditedElement();
221             if (activeField && activeField.editorView && activeField.editorView.getQuickEditUISettings().padding) {
222               horizontalPadding = 5;
223             }
224             break;
225
226           case 3:
227             // Position against a highlighted field.
228             highlightedField = Drupal.quickedit.app.model.get('highlightedField');
229             of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement();
230             delay = 250;
231             break;
232
233           default:
234             var fieldModels = this.model.get('fields').models;
235             var topMostPosition = 1000000;
236             var topMostField = null;
237             // Position against the topmost field.
238             for (let i = 0; i < fieldModels.length; i++) {
239               const pos = fieldModels[i].get('el').getBoundingClientRect().top;
240               if (pos < topMostPosition) {
241                 topMostPosition = pos;
242                 topMostField = fieldModels[i];
243               }
244             }
245             of = topMostField.get('el');
246             delay = 50;
247             break;
248         }
249         // Prepare to check the next possible element to position against.
250         check++;
251       } while (!of);
252
253       /**
254        * Refines the positioning algorithm of jquery.ui.position().
255        *
256        * Invoked as the 'using' callback of jquery.ui.position() in
257        * positionToolbar().
258        *
259        * @param {*} view
260        *   The view the positions will be calculated from.
261        * @param {object} suggested
262        *   A hash of top and left values for the position that should be set. It
263        *   can be forwarded to .css() or .animate().
264        * @param {object} info
265        *   The position and dimensions of both the 'my' element and the 'of'
266        *   elements, as well as calculations to their relative position. This
267        *   object contains the following properties:
268        * @param {object} info.element
269        *   A hash that contains information about the HTML element that will be
270        *   positioned. Also known as the 'my' element.
271        * @param {object} info.target
272        *   A hash that contains information about the HTML element that the
273        *   'my' element will be positioned against. Also known as the 'of'
274        *   element.
275        */
276       function refinePosition(view, suggested, info) {
277         // Determine if the pointer should be on the top or bottom.
278         const isBelow = suggested.top > info.target.top;
279         info.element.element.toggleClass('quickedit-toolbar-pointer-top', isBelow);
280         // Don't position the toolbar past the first or last editable field if
281         // the entity is the target.
282         if (view.$entity[0] === info.target.element[0]) {
283           // Get the first or last field according to whether the toolbar is
284           // above or below the entity.
285           const $field = view.$entity.find('.quickedit-editable').eq((isBelow) ? -1 : 0);
286           if ($field.length > 0) {
287             suggested.top = (isBelow) ? ($field.offset().top + $field.outerHeight(true)) : $field.offset().top - info.element.element.outerHeight(true);
288           }
289         }
290         // Don't let the toolbar go outside the fence.
291         const fenceTop = view.$fence.offset().top;
292         const fenceHeight = view.$fence.height();
293         const toolbarHeight = info.element.element.outerHeight(true);
294         if (suggested.top < fenceTop) {
295           suggested.top = fenceTop;
296         }
297         else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) {
298           suggested.top = fenceTop + fenceHeight - toolbarHeight;
299         }
300         // Position the toolbar.
301         info.element.element.css({
302           left: Math.floor(suggested.left),
303           top: Math.floor(suggested.top),
304         });
305       }
306
307       /**
308        * Calls the jquery.ui.position() method on the $el of this view.
309        */
310       function positionToolbar() {
311         that.$el
312           .position({
313             my: `${edge} bottom`,
314             // Move the toolbar 1px towards the start edge of the 'of' element,
315             // plus any horizontal padding that may have been added to the
316             // element that is being added, to prevent unwanted horizontal
317             // movement.
318             at: `${edge}+${1 + horizontalPadding} top`,
319             of,
320             collision: 'flipfit',
321             using: refinePosition.bind(null, that),
322             within: that.$fence,
323           })
324           // Resize the toolbar to match the dimensions of the field, up to a
325           // maximum width that is equal to 90% of the field's width.
326           .css({
327             'max-width': (document.documentElement.clientWidth < 450) ? document.documentElement.clientWidth : 450,
328             // Set a minimum width of 240px for the entity toolbar, or the width
329             // of the client if it is less than 240px, so that the toolbar
330             // never folds up into a squashed and jumbled mess.
331             'min-width': (document.documentElement.clientWidth < 240) ? document.documentElement.clientWidth : 240,
332             width: '100%',
333           });
334       }
335
336       // Uses the jQuery.ui.position() method. Use a timeout to move the toolbar
337       // only after the user has focused on an editable for 250ms. This prevents
338       // the toolbar from jumping around the screen.
339       this.timer = setTimeout(() => {
340         // Render the position in the next execution cycle, so that animations
341         // on the field have time to process. This is not strictly speaking, a
342         // guarantee that all animations will be finished, but it's a simple
343         // way to get better positioning without too much additional code.
344         _.defer(positionToolbar);
345       }, delay);
346     },
347
348     /**
349      * Set the model state to 'saving' when the save button is clicked.
350      *
351      * @param {jQuery.Event} event
352      *   The click event.
353      */
354     onClickSave(event) {
355       event.stopPropagation();
356       event.preventDefault();
357       // Save the model.
358       this.model.set('state', 'committing');
359     },
360
361     /**
362      * Sets the model state to candidate when the cancel button is clicked.
363      *
364      * @param {jQuery.Event} event
365      *   The click event.
366      */
367     onClickCancel(event) {
368       event.preventDefault();
369       this.model.set('state', 'deactivating');
370     },
371
372     /**
373      * Clears the timeout that will eventually reposition the entity toolbar.
374      *
375      * Without this, it may reposition itself, away from the user's cursor!
376      *
377      * @param {jQuery.Event} event
378      *   The mouse event.
379      */
380     onMouseenter(event) {
381       clearTimeout(this.timer);
382     },
383
384     /**
385      * Builds the entity toolbar HTML; attaches to DOM; sets starting position.
386      *
387      * @return {jQuery}
388      *   The toolbar element.
389      */
390     buildToolbarEl() {
391       const $toolbar = $(Drupal.theme('quickeditEntityToolbar', {
392         id: 'quickedit-entity-toolbar',
393       }));
394
395       $toolbar
396         .find('.quickedit-toolbar-entity')
397         // Append the "ops" toolgroup into the toolbar.
398         .prepend(Drupal.theme('quickeditToolgroup', {
399           classes: ['ops'],
400           buttons: [
401             {
402               label: Drupal.t('Save'),
403               type: 'submit',
404               classes: 'action-save quickedit-button icon',
405               attributes: {
406                 'aria-hidden': true,
407               },
408             },
409             {
410               label: Drupal.t('Close'),
411               classes: 'action-cancel quickedit-button icon icon-close icon-only',
412             },
413           ],
414         }));
415
416       // Give the toolbar a sensible starting position so that it doesn't
417       // animate on to the screen from a far off corner.
418       $toolbar
419         .css({
420           left: this.$entity.offset().left,
421           top: this.$entity.offset().top,
422         });
423
424       return $toolbar;
425     },
426
427     /**
428      * Returns the DOM element that fields will attach their toolbars to.
429      *
430      * @return {jQuery}
431      *   The DOM element that fields will attach their toolbars to.
432      */
433     getToolbarRoot() {
434       return this._fieldToolbarRoot;
435     },
436
437     /**
438      * Generates a state-dependent label for the entity toolbar.
439      */
440     label() {
441       // The entity label.
442       let label = '';
443       const entityLabel = this.model.get('label');
444
445       // Label of an active field, if it exists.
446       const activeField = Drupal.quickedit.app.model.get('activeField');
447       const activeFieldLabel = activeField && activeField.get('metadata').label;
448       // Label of a highlighted field, if it exists.
449       const highlightedField = Drupal.quickedit.app.model.get('highlightedField');
450       const highlightedFieldLabel = highlightedField && highlightedField.get('metadata').label;
451       // The label is constructed in a priority order.
452       if (activeFieldLabel) {
453         label = Drupal.theme('quickeditEntityToolbarLabel', {
454           entityLabel,
455           fieldLabel: activeFieldLabel,
456         });
457       }
458       else if (highlightedFieldLabel) {
459         label = Drupal.theme('quickeditEntityToolbarLabel', {
460           entityLabel,
461           fieldLabel: highlightedFieldLabel,
462         });
463       }
464       else {
465         // @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
466         label = Drupal.checkPlain(entityLabel);
467       }
468
469       this.$el
470         .find('.quickedit-toolbar-label')
471         .html(label);
472     },
473
474     /**
475      * Adds classes to a toolgroup.
476      *
477      * @param {string} toolgroup
478      *   A toolgroup name.
479      * @param {string} classes
480      *   A string of space-delimited class names that will be applied to the
481      *   wrapping element of the toolbar group.
482      */
483     addClass(toolgroup, classes) {
484       this._find(toolgroup).addClass(classes);
485     },
486
487     /**
488      * Removes classes from a toolgroup.
489      *
490      * @param {string} toolgroup
491      *   A toolgroup name.
492      * @param {string} classes
493      *   A string of space-delimited class names that will be removed from the
494      *   wrapping element of the toolbar group.
495      */
496     removeClass(toolgroup, classes) {
497       this._find(toolgroup).removeClass(classes);
498     },
499
500     /**
501      * Finds a toolgroup.
502      *
503      * @param {string} toolgroup
504      *   A toolgroup name.
505      *
506      * @return {jQuery}
507      *   The toolgroup DOM element.
508      */
509     _find(toolgroup) {
510       return this.$el.find(`.quickedit-toolbar .quickedit-toolgroup.${toolgroup}`);
511     },
512
513     /**
514      * Shows a toolgroup.
515      *
516      * @param {string} toolgroup
517      *   A toolgroup name.
518      */
519     show(toolgroup) {
520       this.$el.removeClass('quickedit-animate-invisible');
521     },
522
523   });
524 }(jQuery, _, Backbone, Drupal, Drupal.debounce));