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