• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divio / django-cms / #29379

30 Jan 2025 12:07PM UTC coverage: 76.129% (-0.02%) from 76.152%
#29379

push

travis-ci

web-flow
Merge 3d9caf758 into df4066639

1053 of 1571 branches covered (67.03%)

5 of 10 new or added lines in 2 files covered. (50.0%)

8 existing lines in 1 file now uncovered.

2529 of 3322 relevant lines covered (76.13%)

26.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

59.09
/cms/static/cms/js/modules/cms.plugins.js
1
/*
2
 * Copyright https://github.com/divio/django-cms
3
 */
4
import Modal from './cms.modal';
5
import StructureBoard from './cms.structureboard';
6
import $ from 'jquery';
7
import '../polyfills/array.prototype.findindex';
8
import nextUntil from './nextuntil';
9

10
import { toPairs, filter, isNaN, debounce, findIndex, find, every, uniqWith, once, difference, isEqual } from 'lodash';
11

12
import Class from 'classjs';
13
import { Helpers, KEYS, $window, $document, uid } from './cms.base';
14
import { showLoader, hideLoader } from './loader';
15
import { filter as fuzzyFilter } from 'fuzzaldrin';
16

17
var clipboardDraggable;
18
var path = window.location.pathname + window.location.search;
1✔
19

20
var pluginUsageMap = Helpers._isStorageSupported ? JSON.parse(localStorage.getItem('cms-plugin-usage') || '{}') : {};
1!
21

22
const isStructureReady = () =>
1✔
23
    CMS.config.settings.mode === 'structure' ||
×
24
    CMS.config.settings.legacy_mode ||
25
    CMS.API.StructureBoard._loadedStructure;
26
const isContentReady = () =>
1✔
27
    CMS.config.settings.mode !== 'structure' ||
×
28
    CMS.config.settings.legacy_mode ||
29
    CMS.API.StructureBoard._loadedContent;
30

31
/**
32
 * Class for handling Plugins / Placeholders or Generics.
33
 * Handles adding / moving / copying / pasting / menus etc
34
 * in structureboard.
35
 *
36
 * @class Plugin
37
 * @namespace CMS
38
 * @uses CMS.API.Helpers
39
 */
40
var Plugin = new Class({
1✔
41
    implement: [Helpers],
42

43
    options: {
44
        type: '', // bar, plugin or generic
45
        placeholder_id: null,
46
        plugin_type: '',
47
        plugin_id: null,
48
        plugin_parent: null,
49
        plugin_restriction: [],
50
        plugin_parent_restriction: [],
51
        urls: {
52
            add_plugin: '',
53
            edit_plugin: '',
54
            move_plugin: '',
55
            copy_plugin: '',
56
            delete_plugin: ''
57
        }
58
    },
59

60
    // these properties will be filled later
61
    modal: null,
62

63
    initialize: function initialize(container, options) {
64
        this.options = $.extend(true, {}, this.options, options);
179✔
65

66
        // create an unique for this component to use it internally
67
        this.uid = uid();
179✔
68

69
        this._setupUI(container);
179✔
70
        this._ensureData();
179✔
71

72
        if (this.options.type === 'plugin' && Plugin.aliasPluginDuplicatesMap[this.options.plugin_id]) {
179✔
73
            return;
1✔
74
        }
75
        if (this.options.type === 'placeholder' && Plugin.staticPlaceholderDuplicatesMap[this.options.placeholder_id]) {
178✔
76
            return;
1✔
77
        }
78

79
        // determine type of plugin
80
        switch (this.options.type) {
177✔
81
            case 'placeholder': // handler for placeholder bars
82
                Plugin.staticPlaceholderDuplicatesMap[this.options.placeholder_id] = true;
23✔
83
                this.ui.container.data('cms', this.options);
23✔
84
                this._setPlaceholder();
23✔
85
                if (isStructureReady()) {
23!
86
                    this._collapsables();
23✔
87
                }
88
                break;
23✔
89
            case 'plugin': // handler for all plugins
90
                this.ui.container.data('cms').push(this.options);
130✔
91
                Plugin.aliasPluginDuplicatesMap[this.options.plugin_id] = true;
130✔
92
                this._setPlugin();
130✔
93
                if (isStructureReady()) {
130!
94
                    this._collapsables();
130✔
95
                }
96
                break;
130✔
97
            default:
98
                // handler for static content
99
                this.ui.container.data('cms').push(this.options);
24✔
100
                this._setGeneric();
24✔
101
        }
102
    },
103

104
    _ensureData: function _ensureData() {
105
        // bind data element to the container (mutating!)
106
        if (!this.ui.container.data('cms')) {
179✔
107
            this.ui.container.data('cms', []);
174✔
108
        }
109
    },
110

111
    /**
112
     * Caches some jQuery references and sets up structure for
113
     * further initialisation.
114
     *
115
     * @method _setupUI
116
     * @private
117
     * @param {String} container `cms-plugin-${id}`
118
     */
119
    _setupUI: function setupUI(container) {
120
        var wrapper = $(`.${container}`);
179✔
121
        var contents;
122

123
        // have to check for cms-plugin, there can be a case when there are multiple
124
        // static placeholders or plugins rendered twice, there could be multiple wrappers on same page
125
        if (wrapper.length > 1 && container.match(/cms-plugin/)) {
179✔
126
            // so it's possible that multiple plugins (more often generics) are rendered
127
            // in different places. e.g. page menu in the header and in the footer
128
            // so first, we find all the template tags, then put them in a structure like this:
129
            // [[start, end], [start, end]...]
130
            //
131
            // in case of plugins it means that it's aliased plugin or a plugin in a duplicated
132
            // static placeholder (for whatever reason)
133
            var contentWrappers = wrapper.toArray().reduce((wrappers, elem, index) => {
136✔
134
                if (index === 0) {
274✔
135
                    wrappers[0].push(elem);
136✔
136
                    return wrappers;
136✔
137
                }
138

139
                var lastWrapper = wrappers[wrappers.length - 1];
138✔
140
                var lastItemInWrapper = lastWrapper[lastWrapper.length - 1];
138✔
141

142
                if ($(lastItemInWrapper).is('.cms-plugin-end')) {
138✔
143
                    wrappers.push([elem]);
1✔
144
                } else {
145
                    lastWrapper.push(elem);
137✔
146
                }
147

148
                return wrappers;
138✔
149
            }, [[]]);
150

151
            // then we map that structure into an array of jquery collections
152
            // from which we filter out empty ones
153
            contents = contentWrappers
136✔
154
                .map(items => {
155
                    var templateStart = $(items[0]);
137✔
156
                    var className = templateStart.attr('class').replace('cms-plugin-start', '');
137✔
157

158
                    var itemContents = $(nextUntil(templateStart[0], container));
137✔
159

160
                    $(items).filter('template').remove();
137✔
161

162
                    itemContents.each((index, el) => {
137✔
163
                        // if it's a non-space top-level text node - wrap it in `cms-plugin`
164
                        if (el.nodeType === Node.TEXT_NODE && !el.textContent.match(/^\s*$/)) {
158✔
165
                            var element = $(el);
10✔
166

167
                            element.wrap('<cms-plugin class="cms-plugin-text-node"></cms-plugin>');
10✔
168
                            itemContents[index] = element.parent()[0];
10✔
169
                        }
170
                    });
171

172
                    // otherwise we don't really need text nodes or comment nodes or empty text nodes
173
                    itemContents = itemContents.filter(function() {
137✔
174
                        return this.nodeType !== Node.TEXT_NODE && this.nodeType !== Node.COMMENT_NODE;
158✔
175
                    });
176

177
                    itemContents.addClass(`cms-plugin ${className}`);
137✔
178

179
                    return itemContents;
137✔
180
                })
181
                .filter(v => v.length);
137✔
182

183
            if (contents.length) {
136!
184
                // and then reduce it to one big collection
185
                contents = contents.reduce((collection, items) => collection.add(items), $());
137✔
186
            }
187
        } else {
188
            contents = wrapper;
43✔
189
        }
190

191
        // in clipboard can be non-existent
192
        if (!contents.length) {
179✔
193
            contents = $('<div></div>');
11✔
194
        }
195

196
        this.ui = this.ui || {};
179✔
197
        this.ui.container = contents;
179✔
198
    },
199

200
    /**
201
     * Sets up behaviours and ui for placeholder.
202
     *
203
     * @method _setPlaceholder
204
     * @private
205
     */
206
    _setPlaceholder: function() {
207
        var that = this;
23✔
208

209
        this.ui.dragbar = $('.cms-dragbar-' + this.options.placeholder_id);
23✔
210
        this.ui.draggables = this.ui.dragbar.closest('.cms-dragarea').find('> .cms-draggables');
23✔
211
        this.ui.submenu = this.ui.dragbar.find('.cms-submenu-settings');
23✔
212
        var title = this.ui.dragbar.find('.cms-dragbar-title');
23✔
213
        var togglerLinks = this.ui.dragbar.find('.cms-dragbar-toggler a');
23✔
214
        var expanded = 'cms-dragbar-title-expanded';
23✔
215

216
        // register the subnav on the placeholder
217
        this._setSettingsMenu(this.ui.submenu);
23✔
218
        this._setAddPluginModal(this.ui.dragbar.find('.cms-submenu-add'));
23✔
219

220
        // istanbul ignore next
221
        CMS.settings.dragbars = CMS.settings.dragbars || []; // expanded dragbars array
222

223
        // enable expanding/collapsing globally within the placeholder
224
        togglerLinks.off(Plugin.click).on(Plugin.click, function(e) {
23✔
225
            e.preventDefault();
×
226
            if (title.hasClass(expanded)) {
×
227
                that._collapseAll(title);
×
228
            } else {
229
                that._expandAll(title);
×
230
            }
231
        });
232

233
        if ($.inArray(this.options.placeholder_id, CMS.settings.dragbars) !== -1) {
23!
234
            title.addClass(expanded);
×
235
        }
236

237
        this._checkIfPasteAllowed();
23✔
238
    },
239

240
    /**
241
     * Sets up behaviours and ui for plugin.
242
     *
243
     * @method _setPlugin
244
     * @private
245
     */
246
    _setPlugin: function() {
247
        if (isStructureReady()) {
130!
248
            this._setPluginStructureEvents();
130✔
249
        }
250
        if (isContentReady()) {
130!
251
            this._setPluginContentEvents();
130✔
252
        }
253
    },
254

255
    _setPluginStructureEvents: function _setPluginStructureEvents() {
256
        var that = this;
130✔
257

258
        // filling up ui object
259
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
130✔
260
        this.ui.dragitem = this.ui.draggable.find('> .cms-dragitem');
130✔
261
        this.ui.draggables = this.ui.draggable.find('> .cms-draggables');
130✔
262
        this.ui.submenu = this.ui.dragitem.find('.cms-submenu');
130✔
263

264
        this.ui.draggable.data('cms', this.options);
130✔
265

266
        this.ui.dragitem.on(Plugin.doubleClick, this._dblClickToEditHandler.bind(this));
130✔
267

268
        // adds listener for all plugin updates
269
        this.ui.draggable.off('cms-plugins-update').on('cms-plugins-update', function(e, eventData) {
130✔
270
            e.stopPropagation();
×
271
            that.movePlugin(null, eventData);
×
272
        });
273

274
        // adds listener for copy/paste updates
275
        this.ui.draggable.off('cms-paste-plugin-update').on('cms-paste-plugin-update', function(e, eventData) {
130✔
276
            e.stopPropagation();
5✔
277

278
            var dragitem = $(`.cms-draggable-${eventData.id}:last`);
5✔
279

280
            // find out new placeholder id
281
            var placeholder_id = that._getId(dragitem.closest('.cms-dragarea'));
5✔
282

283
            // if placeholder_id is empty, cancel
284
            if (!placeholder_id) {
5!
285
                return false;
×
286
            }
287

288
            var data = dragitem.data('cms');
5✔
289

290
            data.target = placeholder_id;
5✔
291
            data.parent = that._getId(dragitem.parent().closest('.cms-draggable'));
5✔
292
            data.move_a_copy = true;
5✔
293

294
            // expand the plugin we paste to
295
            CMS.settings.states.push(data.parent);
5✔
296
            Helpers.setSettings(CMS.settings);
5✔
297

298
            that.movePlugin(data);
5✔
299
        });
300

301
        setTimeout(() => {
130✔
302
            this.ui.dragitem
130✔
303
                .on('mouseenter', e => {
304
                    e.stopPropagation();
×
305
                    if (!$document.data('expandmode')) {
×
306
                        return;
×
307
                    }
308
                    if (this.ui.draggable.find('> .cms-dragitem > .cms-plugin-disabled').length) {
×
309
                        return;
×
310
                    }
311
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
312
                        return;
×
313
                    }
314
                    if (CMS.API.StructureBoard.dragging) {
×
315
                        return;
×
316
                    }
317
                    // eslint-disable-next-line no-magic-numbers
318
                    Plugin._highlightPluginContent(this.options.plugin_id, { successTimeout: 0, seeThrough: true });
×
319
                })
320
                .on('mouseleave', e => {
321
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
322
                        return;
×
323
                    }
324
                    e.stopPropagation();
×
325
                    // eslint-disable-next-line no-magic-numbers
326
                    Plugin._removeHighlightPluginContent(this.options.plugin_id);
×
327
                });
328
            // attach event to the plugin menu
329
            this._setSettingsMenu(this.ui.submenu);
130✔
330

331
            // attach events for the "Add plugin" modal
332
            this._setAddPluginModal(this.ui.dragitem.find('.cms-submenu-add'));
130✔
333

334
            // clickability of "Paste" menu item
335
            this._checkIfPasteAllowed();
130✔
336
        });
337
    },
338

339
    _dblClickToEditHandler: function _dblClickToEditHandler(e) {
340
        var that = this;
×
341
        var disabled = $(e.currentTarget).closest('.cms-drag-disabled');
×
NEW
342
        var edit_disabled = $(e.currentTarget).closest('.cms-draggable').hasClass('cms-edit-disabled');
×
343

344
        e.preventDefault();
×
345
        e.stopPropagation();
×
346

NEW
347
        if (!disabled.length && !edit_disabled) {
×
348
            that.editPlugin(
×
349
                Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
350
                that.options.plugin_name,
351
                that._getPluginBreadcrumbs()
352
            );
353
        }
354
    },
355

356
    _setPluginContentEvents: function _setPluginContentEvents() {
357
        const pluginDoubleClickEvent = this._getNamepacedEvent(Plugin.doubleClick);
130✔
358

359
        this.ui.container
130✔
360
            .off('mouseover.cms.plugins')
361
            .on('mouseover.cms.plugins', e => {
362
                if (!$document.data('expandmode')) {
×
363
                    return;
×
364
                }
365
                if (CMS.settings.mode !== 'structure') {
×
366
                    return;
×
367
                }
368
                e.stopPropagation();
×
369
                $('.cms-dragitem-success').remove();
×
370
                $('.cms-draggable-success').removeClass('cms-draggable-success');
×
371
                CMS.API.StructureBoard._showAndHighlightPlugin(0, true); // eslint-disable-line no-magic-numbers
×
372
            })
373
            .off('mouseout.cms.plugins')
374
            .on('mouseout.cms.plugins', e => {
375
                if (CMS.settings.mode !== 'structure') {
×
376
                    return;
×
377
                }
378
                e.stopPropagation();
×
379
                if (this.ui.draggable && this.ui.draggable.length) {
×
380
                    this.ui.draggable.find('.cms-dragitem-success').remove();
×
381
                    this.ui.draggable.removeClass('cms-draggable-success');
×
382
                }
383
                // Plugin._removeHighlightPluginContent(this.options.plugin_id);
384
            });
385

386
        if (!Plugin._isContainingMultiplePlugins(this.ui.container)) {
130✔
387
            // only allow editing by double-click if not disabled
388
            var selector = `.cms-plugin-${this.options.plugin_id}:not(.cms-edit-disabled)`;
129✔
389

390
            $document
129✔
391
                .off(pluginDoubleClickEvent, selector)
392
                .on(
393
                    pluginDoubleClickEvent,
394
                    selector,
395
                    this._dblClickToEditHandler.bind(this)
396
                );
397
        }
398
    },
399

400
    /**
401
     * Sets up behaviours and ui for generics.
402
     * Generics do not show up in structure board.
403
     *
404
     * @method _setGeneric
405
     * @private
406
     */
407
    _setGeneric: function() {
408
        var that = this;
24✔
409

410
        // adds double click to edit
411
        this.ui.container.off(Plugin.doubleClick).on(Plugin.doubleClick, function(e) {
24✔
412
            e.preventDefault();
×
413
            e.stopPropagation();
×
414
            that.editPlugin(Helpers.updateUrlWithPath(that.options.urls.edit_plugin), that.options.plugin_name, []);
×
415
        });
416

417
        // adds edit tooltip
418
        this.ui.container
24✔
419
            .off(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart)
420
            .on(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart, function(e) {
421
                if (e.type !== 'touchstart') {
×
422
                    e.stopPropagation();
×
423
                }
424
                var name = that.options.plugin_name;
×
425
                var id = that.options.plugin_id;
×
NEW
426
                var disabled = $(e.currentTarget).hasClass('.cms-edit-disabled');  // No tooltip for disabled plugins
×
427

NEW
428
                CMS.API.Tooltip.displayToggle(
×
429
                    (e.type === 'pointerover' || e.type === 'touchstart') && !disabled,
×
430
                    e,
431
                    name,
432
                    id
433
                );
434
            });
435
    },
436

437
    /**
438
     * Checks if paste is allowed into current plugin/placeholder based
439
     * on restrictions we have. Also determines which tooltip to show.
440
     *
441
     * WARNING: this relies on clipboard plugins always being instantiated
442
     * first, so they have data('cms') by the time this method is called.
443
     *
444
     * @method _checkIfPasteAllowed
445
     * @private
446
     * @returns {Boolean}
447
     */
448
    _checkIfPasteAllowed: function _checkIfPasteAllowed() {
449
        var pasteButton = this.ui.dropdown.find('[data-rel=paste]');
151✔
450
        var pasteItem = pasteButton.parent();
151✔
451

452
        if (!clipboardDraggable.length) {
151✔
453
            pasteItem.addClass('cms-submenu-item-disabled');
86✔
454
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
86✔
455
            pasteItem.find('.cms-submenu-item-paste-tooltip-empty').css('display', 'block');
86✔
456
            return false;
86✔
457
        }
458

459
        if (this.ui.draggable && this.ui.draggable.hasClass('cms-draggable-disabled')) {
65✔
460
            pasteItem.addClass('cms-submenu-item-disabled');
45✔
461
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
45✔
462
            pasteItem.find('.cms-submenu-item-paste-tooltip-disabled').css('display', 'block');
45✔
463
            return false;
45✔
464
        }
465

466
        var bounds = this.options.plugin_restriction;
20✔
467

468
        if (clipboardDraggable.data('cms')) {
20!
469
            var clipboardPluginData = clipboardDraggable.data('cms');
20✔
470
            var type = clipboardPluginData.plugin_type;
20✔
471
            var parent_bounds = $.grep(clipboardPluginData.plugin_parent_restriction, function(restriction) {
20✔
472
                // special case when PlaceholderPlugin has a parent restriction named "0"
473
                return restriction !== '0';
20✔
474
            });
475
            var currentPluginType = this.options.plugin_type;
20✔
476

477
            if (
20✔
478
                (bounds.length && $.inArray(type, bounds) === -1) ||
60!
479
                (parent_bounds.length && $.inArray(currentPluginType, parent_bounds) === -1)
480
            ) {
481
                pasteItem.addClass('cms-submenu-item-disabled');
15✔
482
                pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
15✔
483
                pasteItem.find('.cms-submenu-item-paste-tooltip-restricted').css('display', 'block');
15✔
484
                return false;
15✔
485
            }
486
        } else {
487
            return false;
×
488
        }
489

490
        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
5✔
491
        pasteItem.removeClass('cms-submenu-item-disabled');
5✔
492

493
        return true;
5✔
494
    },
495

496
    /**
497
     * Calls api to create a plugin and then proceeds to edit it.
498
     *
499
     * @method addPlugin
500
     * @param {String} type type of the plugin, e.g "Bootstrap3ColumnCMSPlugin"
501
     * @param {String} name name of the plugin, e.g. "Column"
502
     * @param {String} parent id of a parent plugin
503
     * @param {Boolean} showAddForm if false, will NOT show the add form
504
     * @param {Number} position (optional) position of the plugin
505
     */
506
    // eslint-disable-next-line max-params
507
    addPlugin: function(type, name, parent, showAddForm = true, position) {
2✔
508
        var params = {
4✔
509
            placeholder_id: this.options.placeholder_id,
510
            plugin_type: type,
511
            cms_path: path,
512
            plugin_language: CMS.config.request.language,
513
            plugin_position: position || this._getPluginAddPosition()
8✔
514
        };
515

516
        if (parent) {
4✔
517
            params.plugin_parent = parent;
2✔
518
        }
519
        var url = this.options.urls.add_plugin + '?' + $.param(params);
4✔
520

521
        const modal = new Modal({
4✔
522
            onClose: this.options.onClose || false,
7✔
523
            redirectOnClose: this.options.redirectOnClose || false
7✔
524
        });
525

526
        if (showAddForm) {
4✔
527
            modal.open({
3✔
528
                url: url,
529
                title: name
530
            });
531
        } else {
532
            // Also open the modal but without the content. Instead create a form and immediately submit it.
533
            modal.open({
1✔
534
                url: '#',
535
                title: name
536
            });
537
            if (modal.ui) {
1!
538
                // Hide the plugin type selector modal if it's open
539
                modal.ui.modal.hide();
1✔
540
            }
541
            const contents = modal.ui.frame.find('iframe').contents();
1✔
542
            const body = contents.find('body');
1✔
543

544
            body.append(`<form method="post" action="${url}" style="display: none;">
1✔
545
                <input type="hidden" name="csrfmiddlewaretoken" value="${CMS.config.csrf}"></form>`);
546
            body.find('form').submit();
1✔
547
        }
548
        this.modal = modal;
4✔
549

550
        Helpers.removeEventListener('modal-closed.add-plugin');
4✔
551
        Helpers.addEventListener('modal-closed.add-plugin', (e, { instance }) => {
4✔
552
            if (instance !== modal) {
1!
553
                return;
×
554
            }
555
            Plugin._removeAddPluginPlaceholder();
1✔
556
        });
557
    },
558

559
    _getPluginAddPosition: function() {
560
        if (this.options.type === 'placeholder') {
×
561
            return $(`.cms-dragarea-${this.options.placeholder_id} .cms-draggable`).length + 1;
×
562
        }
563

564
        // assume plugin now
565
        // would prefer to get the information from the tree, but the problem is that the flat data
566
        // isn't sorted by position
567
        const maybeChildren = this.ui.draggable.find('.cms-draggable');
×
568

569
        if (maybeChildren.length) {
×
570
            const lastChild = maybeChildren.last();
×
571

572
            const lastChildInstance = Plugin._getPluginById(this._getId(lastChild));
×
573

574
            return lastChildInstance.options.position + 1;
×
575
        }
576

577
        return this.options.position + 1;
×
578
    },
579

580
    /**
581
     * Opens the modal for editing a plugin.
582
     *
583
     * @method editPlugin
584
     * @param {String} url editing url
585
     * @param {String} name Name of the plugin, e.g. "Column"
586
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
587
     *     each item is `{ title: 'string': url: 'string' }`
588
     */
589
    editPlugin: function(url, name, breadcrumb) {
590
        // trigger modal window
591
        var modal = new Modal({
3✔
592
            onClose: this.options.onClose || false,
6✔
593
            redirectOnClose: this.options.redirectOnClose || false
6✔
594
        });
595

596
        this.modal = modal;
3✔
597

598
        Helpers.removeEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin');
3✔
599
        Helpers.addEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin', (e, { instance }) => {
3✔
600
            if (instance === modal) {
1!
601
                // cannot be cached
602
                Plugin._removeAddPluginPlaceholder();
1✔
603
            }
604
        });
605
        modal.open({
3✔
606
            url: url,
607
            title: name,
608
            breadcrumbs: breadcrumb,
609
            width: 850
610
        });
611
    },
612

613
    /**
614
     * Used for copying _and_ pasting a plugin. If either of params
615
     * is present method assumes that it's "paste" and will make a call
616
     * to api to insert current plugin to specified `options.target_plugin_id`
617
     * or `options.target_placeholder_id`. Copying a plugin also first
618
     * clears the clipboard.
619
     *
620
     * @method copyPlugin
621
     * @param {Object} [opts=this.options]
622
     * @param {String} source_language
623
     * @returns {Boolean|void}
624
     */
625
    // eslint-disable-next-line complexity
626
    copyPlugin: function(opts, source_language) {
627
        // cancel request if already in progress
628
        if (CMS.API.locked) {
9✔
629
            return false;
1✔
630
        }
631
        CMS.API.locked = true;
8✔
632

633
        // set correct options (don't mutate them)
634
        var options = $.extend({}, opts || this.options);
8✔
635
        var sourceLanguage = source_language;
8✔
636
        let copyingFromLanguage = false;
8✔
637

638
        if (sourceLanguage) {
8✔
639
            copyingFromLanguage = true;
1✔
640
            options.target = options.placeholder_id;
1✔
641
            options.plugin_id = '';
1✔
642
            options.parent = '';
1✔
643
        } else {
644
            sourceLanguage = CMS.config.request.language;
7✔
645
        }
646

647
        var data = {
8✔
648
            source_placeholder_id: options.placeholder_id,
649
            source_plugin_id: options.plugin_id || '',
9✔
650
            source_language: sourceLanguage,
651
            target_plugin_id: options.parent || '',
16✔
652
            target_placeholder_id: options.target || CMS.config.clipboard.id,
15✔
653
            csrfmiddlewaretoken: CMS.config.csrf,
654
            target_language: CMS.config.request.language
655
        };
656
        var request = {
8✔
657
            type: 'POST',
658
            url: Helpers.updateUrlWithPath(options.urls.copy_plugin),
659
            data: data,
660
            success: function(response) {
661
                CMS.API.Messages.open({
2✔
662
                    message: CMS.config.lang.success
663
                });
664
                if (copyingFromLanguage) {
2!
665
                    CMS.API.StructureBoard.invalidateState('PASTE', $.extend({}, data, response));
×
666
                } else {
667
                    CMS.API.StructureBoard.invalidateState('COPY', response);
2✔
668
                }
669
                CMS.API.locked = false;
2✔
670
                hideLoader();
2✔
671
            },
672
            error: function(jqXHR) {
673
                CMS.API.locked = false;
3✔
674
                var msg = CMS.config.lang.error;
3✔
675

676
                // trigger error
677
                CMS.API.Messages.open({
3✔
678
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
679
                    error: true
680
                });
681
            }
682
        };
683

684
        $.ajax(request);
8✔
685
    },
686

687
    /**
688
     * Essentially clears clipboard and moves plugin to a clipboard
689
     * placholder through `movePlugin`.
690
     *
691
     * @method cutPlugin
692
     * @returns {Boolean|void}
693
     */
694
    cutPlugin: function() {
695
        // if cut is once triggered, prevent additional actions
696
        if (CMS.API.locked) {
9✔
697
            return false;
1✔
698
        }
699
        CMS.API.locked = true;
8✔
700

701
        var that = this;
8✔
702
        var data = {
8✔
703
            placeholder_id: CMS.config.clipboard.id,
704
            plugin_id: this.options.plugin_id,
705
            plugin_parent: '',
706
            target_language: CMS.config.request.language,
707
            csrfmiddlewaretoken: CMS.config.csrf
708
        };
709

710
        // move plugin
711
        $.ajax({
8✔
712
            type: 'POST',
713
            url: Helpers.updateUrlWithPath(that.options.urls.move_plugin),
714
            data: data,
715
            success: function(response) {
716
                CMS.API.locked = false;
4✔
717
                CMS.API.Messages.open({
4✔
718
                    message: CMS.config.lang.success
719
                });
720
                CMS.API.StructureBoard.invalidateState('CUT', $.extend({}, data, response));
4✔
721
                hideLoader();
4✔
722
            },
723
            error: function(jqXHR) {
724
                CMS.API.locked = false;
3✔
725
                var msg = CMS.config.lang.error;
3✔
726

727
                // trigger error
728
                CMS.API.Messages.open({
3✔
729
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
730
                    error: true
731
                });
732
                hideLoader();
3✔
733
            }
734
        });
735
    },
736

737
    /**
738
     * Method is called when you click on the paste button on the plugin.
739
     * Uses existing solution of `copyPlugin(options)`
740
     *
741
     * @method pastePlugin
742
     */
743
    pastePlugin: function() {
744
        var id = this._getId(clipboardDraggable);
5✔
745
        var eventData = {
5✔
746
            id: id
747
        };
748

749
        const clipboardDraggableClone = clipboardDraggable.clone(true, true);
5✔
750

751
        clipboardDraggableClone.appendTo(this.ui.draggables);
5✔
752
        if (this.options.plugin_id) {
5✔
753
            StructureBoard.actualizePluginCollapseStatus(this.options.plugin_id);
4✔
754
        }
755
        this.ui.draggables.trigger('cms-structure-update', [eventData]);
5✔
756
        clipboardDraggableClone.trigger('cms-paste-plugin-update', [eventData]);
5✔
757
    },
758

759
    /**
760
     * Moves plugin by querying the API and then updates some UI parts
761
     * to reflect that the page has changed.
762
     *
763
     * @method movePlugin
764
     * @param {Object} [opts=this.options]
765
     * @param {String} [opts.placeholder_id]
766
     * @param {String} [opts.plugin_id]
767
     * @param {String} [opts.plugin_parent]
768
     * @param {Boolean} [opts.move_a_copy]
769
     * @returns {Boolean|void}
770
     */
771
    movePlugin: function(opts) {
772
        // cancel request if already in progress
773
        if (CMS.API.locked) {
12✔
774
            return false;
1✔
775
        }
776
        CMS.API.locked = true;
11✔
777

778
        // set correct options
779
        var options = opts || this.options;
11✔
780

781
        var dragitem = $(`.cms-draggable-${options.plugin_id}:last`);
11✔
782

783
        // SAVING POSITION
784
        var placeholder_id = this._getId(dragitem.parents('.cms-draggables').last().prevAll('.cms-dragbar').first());
11✔
785

786
        // cancel here if we have no placeholder id
787
        if (placeholder_id === false) {
11✔
788
            return false;
1✔
789
        }
790
        var pluginParentElement = dragitem.parent().closest('.cms-draggable');
10✔
791
        var plugin_parent = this._getId(pluginParentElement);
10✔
792

793
        // gather the data for ajax request
794
        var data = {
10✔
795
            plugin_id: options.plugin_id,
796
            plugin_parent: plugin_parent || '',
20✔
797
            target_language: CMS.config.request.language,
798
            csrfmiddlewaretoken: CMS.config.csrf,
799
            move_a_copy: options.move_a_copy
800
        };
801

802
        if (Number(placeholder_id) === Number(options.placeholder_id)) {
10!
803
            Plugin._updatePluginPositions(options.placeholder_id);
10✔
804
        } else {
805
            data.placeholder_id = placeholder_id;
×
806

807
            Plugin._updatePluginPositions(placeholder_id);
×
808
            Plugin._updatePluginPositions(options.placeholder_id);
×
809
        }
810

811
        var position = this.options.position;
10✔
812

813
        data.target_position = position;
10✔
814

815
        showLoader();
10✔
816

817
        $.ajax({
10✔
818
            type: 'POST',
819
            url: Helpers.updateUrlWithPath(options.urls.move_plugin),
820
            data: data,
821
            success: function(response) {
822
                CMS.API.StructureBoard.invalidateState(
4✔
823
                    data.move_a_copy ? 'PASTE' : 'MOVE',
4!
824
                    $.extend({}, data, { placeholder_id: placeholder_id }, response)
825
                );
826

827
                // enable actions again
828
                CMS.API.locked = false;
4✔
829
                hideLoader();
4✔
830
            },
831
            error: function(jqXHR) {
832
                CMS.API.locked = false;
4✔
833
                var msg = CMS.config.lang.error;
4✔
834

835
                // trigger error
836
                CMS.API.Messages.open({
4✔
837
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
5✔
838
                    error: true
839
                });
840
                hideLoader();
4✔
841
            }
842
        });
843
    },
844

845
    /**
846
     * Changes the settings attributes on an initialised plugin.
847
     *
848
     * @method _setSettings
849
     * @param {Object} oldSettings current settings
850
     * @param {Object} newSettings new settings to be applied
851
     * @private
852
     */
853
    _setSettings: function _setSettings(oldSettings, newSettings) {
854
        var settings = $.extend(true, {}, oldSettings, newSettings);
×
855
        var plugin = $('.cms-plugin-' + settings.plugin_id);
×
856
        var draggable = $('.cms-draggable-' + settings.plugin_id);
×
857

858
        // set new setting on instance and plugin data
859
        this.options = settings;
×
860
        if (plugin.length) {
×
861
            var index = plugin.data('cms').findIndex(function(pluginData) {
×
862
                return pluginData.plugin_id === settings.plugin_id;
×
863
            });
864

865
            plugin.each(function() {
×
866
                $(this).data('cms')[index] = settings;
×
867
            });
868
        }
869
        if (draggable.length) {
×
870
            draggable.data('cms', settings);
×
871
        }
872
    },
873

874
    /**
875
     * Opens a modal to delete a plugin.
876
     *
877
     * @method deletePlugin
878
     * @param {String} url admin url for deleting a page
879
     * @param {String} name plugin name, e.g. "Column"
880
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
881
     *     each item is `{ title: 'string': url: 'string' }`
882
     */
883
    deletePlugin: function(url, name, breadcrumb) {
884
        // trigger modal window
885
        var modal = new Modal({
2✔
886
            onClose: this.options.onClose || false,
4✔
887
            redirectOnClose: this.options.redirectOnClose || false
4✔
888
        });
889

890
        this.modal = modal;
2✔
891

892
        Helpers.removeEventListener('modal-loaded.delete-plugin');
2✔
893
        Helpers.addEventListener('modal-loaded.delete-plugin', (e, { instance }) => {
2✔
894
            if (instance === modal) {
5✔
895
                Plugin._removeAddPluginPlaceholder();
1✔
896
            }
897
        });
898
        modal.open({
2✔
899
            url: url,
900
            title: name,
901
            breadcrumbs: breadcrumb
902
        });
903
    },
904

905
    /**
906
     * Destroys the current plugin instance removing only the DOM listeners
907
     *
908
     * @method destroy
909
     * @param {Object}  options - destroy config options
910
     * @param {Boolean} options.mustCleanup - if true it will remove also the plugin UI components from the DOM
911
     * @returns {void}
912
     */
913
    destroy(options = {}) {
1✔
914
        const mustCleanup = options.mustCleanup || false;
2✔
915

916
        // close the plugin modal if it was open
917
        if (this.modal) {
2!
918
            this.modal.close();
×
919
            // unsubscribe to all the modal events
920
            this.modal.off();
×
921
        }
922

923
        if (mustCleanup) {
2✔
924
            this.cleanup();
1✔
925
        }
926

927
        // remove event bound to global elements like document or window
928
        $document.off(`.${this.uid}`);
2✔
929
        $window.off(`.${this.uid}`);
2✔
930
    },
931

932
    /**
933
     * Remove the plugin specific ui elements from the DOM
934
     *
935
     * @method cleanup
936
     * @returns {void}
937
     */
938
    cleanup() {
939
        // remove all the plugin UI DOM elements
940
        // notice that $.remove will remove also all the ui specific events
941
        // previously attached to them
942
        Object.keys(this.ui).forEach(el => this.ui[el].remove());
12✔
943
    },
944

945
    /**
946
     * Called after plugin is added through ajax.
947
     *
948
     * @method editPluginPostAjax
949
     * @param {Object} toolbar CMS.API.Toolbar instance (not used)
950
     * @param {Object} response response from server
951
     */
952
    editPluginPostAjax: function(toolbar, response) {
953
        this.editPlugin(Helpers.updateUrlWithPath(response.url), this.options.plugin_name, response.breadcrumb);
1✔
954
    },
955

956
    /**
957
     * _setSettingsMenu sets up event handlers for settings menu.
958
     *
959
     * @method _setSettingsMenu
960
     * @private
961
     * @param {jQuery} nav
962
     */
963
    _setSettingsMenu: function _setSettingsMenu(nav) {
964
        var that = this;
153✔
965

966
        this.ui.dropdown = nav.siblings('.cms-submenu-dropdown-settings');
153✔
967
        var dropdown = this.ui.dropdown;
153✔
968

969
        nav
153✔
970
            .off(Plugin.pointerUp)
971
            .on(Plugin.pointerUp, function(e) {
972
                e.preventDefault();
×
973
                e.stopPropagation();
×
974
                var trigger = $(this);
×
975

976
                if (trigger.hasClass('cms-btn-active')) {
×
977
                    Plugin._hideSettingsMenu(trigger);
×
978
                } else {
979
                    Plugin._hideSettingsMenu();
×
980
                    that._showSettingsMenu(trigger);
×
981
                }
982
            })
983
            .off(Plugin.touchStart)
984
            .on(Plugin.touchStart, function(e) {
985
                // required on some touch devices so
986
                // ui touch punch is not triggering mousemove
987
                // which in turn results in pep triggering pointercancel
988
                e.stopPropagation();
×
989
            });
990

991
        dropdown
153✔
992
            .off(Plugin.mouseEvents)
993
            .on(Plugin.mouseEvents, function(e) {
994
                e.stopPropagation();
×
995
            })
996
            .off(Plugin.touchStart)
997
            .on(Plugin.touchStart, function(e) {
998
                // required for scrolling on mobile
999
                e.stopPropagation();
×
1000
            });
1001

1002
        that._setupActions(nav);
153✔
1003
        // prevent propagation
1004
        nav
153✔
1005
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '))
1006
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1007
                e.stopPropagation();
×
1008
            });
1009

1010
        nav
153✔
1011
            .siblings('.cms-quicksearch, .cms-submenu-dropdown-settings')
1012
            .off([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '))
1013
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1014
                e.stopPropagation();
×
1015
            });
1016
    },
1017

1018
    /**
1019
     * Simplistic implementation, only scrolls down, only works in structuremode
1020
     * and highly depends on the styles of the structureboard to work correctly
1021
     *
1022
     * @method _scrollToElement
1023
     * @private
1024
     * @param {jQuery} el element to scroll to
1025
     * @param {Object} [opts]
1026
     * @param {Number} [opts.duration=200] time to scroll
1027
     * @param {Number} [opts.offset=50] distance in px to the bottom of the screen
1028
     */
1029
    _scrollToElement: function _scrollToElement(el, opts) {
1030
        var DEFAULT_DURATION = 200;
3✔
1031
        var DEFAULT_OFFSET = 50;
3✔
1032
        var duration = opts && opts.duration !== undefined ? opts.duration : DEFAULT_DURATION;
3✔
1033
        var offset = opts && opts.offset !== undefined ? opts.offset : DEFAULT_OFFSET;
3✔
1034
        var scrollable = el.offsetParent();
3✔
1035
        var scrollHeight = $window.height();
3✔
1036
        var scrollTop = scrollable.scrollTop();
3✔
1037
        var elPosition = el.position().top;
3✔
1038
        var elHeight = el.height();
3✔
1039
        var isInViewport = elPosition + elHeight + offset <= scrollHeight;
3✔
1040

1041
        if (!isInViewport) {
3✔
1042
            scrollable.animate(
2✔
1043
                {
1044
                    scrollTop: elPosition + offset + elHeight + scrollTop - scrollHeight
1045
                },
1046
                duration
1047
            );
1048
        }
1049
    },
1050

1051
    /**
1052
     * Opens a modal with traversable plugins list, adds a placeholder to where
1053
     * the plugin will be added.
1054
     *
1055
     * @method _setAddPluginModal
1056
     * @private
1057
     * @param {jQuery} nav modal trigger element
1058
     * @returns {Boolean|void}
1059
     */
1060
    _setAddPluginModal: function _setAddPluginModal(nav) {
1061
        if (nav.hasClass('cms-btn-disabled')) {
153✔
1062
            return false;
88✔
1063
        }
1064
        var that = this;
65✔
1065
        var modal;
1066
        var possibleChildClasses;
1067
        var isTouching;
1068
        var plugins;
1069

1070
        var initModal = once(function initModal() {
65✔
1071
            var placeholder = $(
×
1072
                '<div class="cms-add-plugin-placeholder">' + CMS.config.lang.addPluginPlaceholder + '</div>'
1073
            );
1074
            var dragItem = nav.closest('.cms-dragitem');
×
1075
            var isPlaceholder = !dragItem.length;
×
1076
            var childrenList;
1077

1078
            modal = new Modal({
×
1079
                minWidth: 400,
1080
                minHeight: 400
1081
            });
1082

1083
            if (isPlaceholder) {
×
1084
                childrenList = nav.closest('.cms-dragarea').find('> .cms-draggables');
×
1085
            } else {
1086
                childrenList = nav.closest('.cms-draggable').find('> .cms-draggables');
×
1087
            }
1088

1089
            Helpers.addEventListener('modal-loaded', (e, { instance }) => {
×
1090
                if (instance !== modal) {
×
1091
                    return;
×
1092
                }
1093

1094
                that._setupKeyboardTraversing();
×
1095
                if (childrenList.hasClass('cms-hidden') && !isPlaceholder) {
×
1096
                    that._toggleCollapsable(dragItem);
×
1097
                }
1098
                Plugin._removeAddPluginPlaceholder();
×
1099
                placeholder.appendTo(childrenList);
×
1100
                that._scrollToElement(placeholder);
×
1101
            });
1102

1103
            Helpers.addEventListener('modal-closed', (e, { instance }) => {
×
1104
                if (instance !== modal) {
×
1105
                    return;
×
1106
                }
1107
                Plugin._removeAddPluginPlaceholder();
×
1108
            });
1109

1110
            Helpers.addEventListener('modal-shown', (e, { instance }) => {
×
1111
                if (modal !== instance) {
×
1112
                    return;
×
1113
                }
1114
                var dropdown = $('.cms-modal-markup .cms-plugin-picker');
×
1115

1116
                if (!isTouching) {
×
1117
                    // only focus the field if using mouse
1118
                    // otherwise keyboard pops up
1119
                    dropdown.find('input').trigger('focus');
×
1120
                }
1121
                isTouching = false;
×
1122
            });
1123

1124
            plugins = nav.siblings('.cms-plugin-picker');
×
1125

1126
            that._setupQuickSearch(plugins);
×
1127
        });
1128

1129
        nav
65✔
1130
            .on(Plugin.touchStart, function(e) {
1131
                isTouching = true;
×
1132
                // required on some touch devices so
1133
                // ui touch punch is not triggering mousemove
1134
                // which in turn results in pep triggering pointercancel
1135
                e.stopPropagation();
×
1136
            })
1137
            .on(Plugin.pointerUp, function(e) {
1138
                e.preventDefault();
×
1139
                e.stopPropagation();
×
1140

1141
                Plugin._hideSettingsMenu();
×
1142

1143
                possibleChildClasses = that._getPossibleChildClasses();
×
1144
                var selectionNeeded = possibleChildClasses.filter(':not(.cms-submenu-item-title)').length !== 1;
×
1145

1146
                if (selectionNeeded) {
×
1147
                    initModal();
×
1148

1149
                    // since we don't know exact plugin parent (because dragndrop)
1150
                    // we need to know the parent id by the time we open "add plugin" dialog
1151
                    var pluginsCopy = that._updateWithMostUsedPlugins(
×
1152
                        plugins
1153
                            .clone(true, true)
1154
                            .data('parentId', that._getId(nav.closest('.cms-draggable')))
1155
                            .append(possibleChildClasses)
1156
                    );
1157

1158
                    modal.open({
×
1159
                        title: that.options.addPluginHelpTitle,
1160
                        html: pluginsCopy,
1161
                        width: 530,
1162
                        height: 400
1163
                    });
1164
                } else {
1165
                    // only one plugin available, no need to show the modal
1166
                    // instead directly add the single plugin
1167
                    const el = possibleChildClasses.find('a');  // only one result
×
1168
                    const pluginType = el.attr('href').replace('#', '');
×
1169
                    const showAddForm = el.data('addForm');
×
1170
                    const parentId = that._getId(nav.closest('.cms-draggable'));
×
1171

1172
                    that.addPlugin(pluginType, el.text(), parentId, showAddForm);
×
1173
                }
1174
            });
1175

1176
        // prevent propagation
1177
        nav.on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
65✔
1178
            e.stopPropagation();
×
1179
        });
1180

1181
        nav
65✔
1182
            .siblings('.cms-quicksearch, .cms-submenu-dropdown')
1183
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1184
                e.stopPropagation();
×
1185
            });
1186
    },
1187

1188
    _updateWithMostUsedPlugins: function _updateWithMostUsedPlugins(plugins) {
1189
        const items = plugins.find('.cms-submenu-item');
×
1190
        // eslint-disable-next-line no-unused-vars
1191
        const mostUsedPlugins = toPairs(pluginUsageMap).sort(([x, a], [y, b]) => a - b).reverse();
×
1192
        const MAX_MOST_USED_PLUGINS = 5;
×
1193
        let count = 0;
×
1194

1195
        if (items.filter(':not(.cms-submenu-item-title)').length <= MAX_MOST_USED_PLUGINS) {
×
1196
            return plugins;
×
1197
        }
1198

1199
        let ref = plugins.find('.cms-quicksearch');
×
1200

1201
        mostUsedPlugins.forEach(([name]) => {
×
1202
            if (count === MAX_MOST_USED_PLUGINS) {
×
1203
                return;
×
1204
            }
1205
            const item = items.find(`[href=${name}]`);
×
1206

1207
            if (item.length) {
×
1208
                const clone = item.closest('.cms-submenu-item').clone(true, true);
×
1209

1210
                ref.after(clone);
×
1211
                ref = clone;
×
1212
                count += 1;
×
1213
            }
1214
        });
1215

1216
        if (count) {
×
1217
            plugins.find('.cms-quicksearch').after(
×
1218
                $(`<div class="cms-submenu-item cms-submenu-item-title" data-cms-most-used>
1219
                    <span>${CMS.config.lang.mostUsed}</span>
1220
                </div>`)
1221
            );
1222
        }
1223

1224
        return plugins;
×
1225
    },
1226

1227
    /**
1228
     * Returns a specific plugin namespaced event postfixing the plugin uid to it
1229
     * in order to properly manage it via jQuery $.on and $.off
1230
     *
1231
     * @method _getNamepacedEvent
1232
     * @private
1233
     * @param {String} base - plugin event type
1234
     * @param {String} additionalNS - additional namespace (like '.traverse' for example)
1235
     * @returns {String} a specific plugin event
1236
     *
1237
     * @example
1238
     *
1239
     * plugin._getNamepacedEvent(Plugin.click); // 'click.cms.plugin.42'
1240
     * plugin._getNamepacedEvent(Plugin.keyDown, '.traverse'); // 'keydown.cms.plugin.traverse.42'
1241
     */
1242
    _getNamepacedEvent(base, additionalNS = '') {
133✔
1243
        return `${base}${additionalNS ? '.'.concat(additionalNS) : ''}.${this.uid}`;
144✔
1244
    },
1245

1246
    /**
1247
     * Returns available plugin/placeholder child classes markup
1248
     * for "Add plugin" modal
1249
     *
1250
     * @method _getPossibleChildClasses
1251
     * @private
1252
     * @returns {jQuery} "add plugin" menu
1253
     */
1254
    _getPossibleChildClasses: function _getPossibleChildClasses() {
1255
        var that = this;
33✔
1256
        var childRestrictions = this.options.plugin_restriction;
33✔
1257
        // have to check the placeholder every time, since plugin could've been
1258
        // moved as part of another plugin
1259
        var placeholderId = that._getId(that.ui.submenu.closest('.cms-dragarea'));
33✔
1260
        var resultElements = $($('#cms-plugin-child-classes-' + placeholderId).html());
33✔
1261

1262
        if (childRestrictions && childRestrictions.length) {
33✔
1263
            resultElements = resultElements.filter(function() {
29✔
1264
                var item = $(this);
4,727✔
1265

1266
                return (
4,727✔
1267
                    item.hasClass('cms-submenu-item-title') ||
9,106✔
1268
                    childRestrictions.indexOf(item.find('a').attr('href')) !== -1
1269
                );
1270
            });
1271

1272
            resultElements = resultElements.filter(function(index) {
29✔
1273
                var item = $(this);
411✔
1274

1275
                return (
411✔
1276
                    !item.hasClass('cms-submenu-item-title') ||
1,182✔
1277
                    (item.hasClass('cms-submenu-item-title') &&
1278
                        (!resultElements.eq(index + 1).hasClass('cms-submenu-item-title') &&
1279
                            resultElements.eq(index + 1).length))
1280
                );
1281
            });
1282
        }
1283

1284
        resultElements.find('a').on(Plugin.click, e => this._delegate(e));
33✔
1285

1286
        return resultElements;
33✔
1287
    },
1288

1289
    /**
1290
     * Sets up event handlers for quicksearching in the plugin picker.
1291
     *
1292
     * @method _setupQuickSearch
1293
     * @private
1294
     * @param {jQuery} plugins plugins picker element
1295
     */
1296
    _setupQuickSearch: function _setupQuickSearch(plugins) {
1297
        var that = this;
×
1298
        var FILTER_DEBOUNCE_TIMER = 100;
×
1299
        var FILTER_PICK_DEBOUNCE_TIMER = 110;
×
1300

1301
        var handler = debounce(function() {
×
1302
            var input = $(this);
×
1303
            // have to always find the pluginsPicker in the handler
1304
            // because of how we move things into/out of the modal
1305
            var pluginsPicker = input.closest('.cms-plugin-picker');
×
1306

1307
            that._filterPluginsList(pluginsPicker, input);
×
1308
        }, FILTER_DEBOUNCE_TIMER);
1309

1310
        plugins.find('> .cms-quicksearch').find('input').on(Plugin.keyUp, handler).on(
×
1311
            Plugin.keyUp,
1312
            debounce(function(e) {
1313
                var input;
1314
                var pluginsPicker;
1315

1316
                if (e.keyCode === KEYS.ENTER) {
×
1317
                    input = $(this);
×
1318
                    pluginsPicker = input.closest('.cms-plugin-picker');
×
1319
                    pluginsPicker
×
1320
                        .find('.cms-submenu-item')
1321
                        .not('.cms-submenu-item-title')
1322
                        .filter(':visible')
1323
                        .first()
1324
                        .find('> a')
1325
                        .focus()
1326
                        .trigger('click');
1327
                }
1328
            }, FILTER_PICK_DEBOUNCE_TIMER)
1329
        );
1330
    },
1331

1332
    /**
1333
     * Sets up click handlers for various plugin/placeholder items.
1334
     * Items can be anywhere in the plugin dragitem, not only in dropdown.
1335
     *
1336
     * @method _setupActions
1337
     * @private
1338
     * @param {jQuery} nav dropdown trigger with the items
1339
     */
1340
    _setupActions: function _setupActions(nav) {
1341
        var items = '.cms-submenu-edit, .cms-submenu-item a';
163✔
1342
        var parent = nav.parent();
163✔
1343

1344
        parent.find('.cms-submenu-edit').off(Plugin.touchStart).on(Plugin.touchStart, function(e) {
163✔
1345
            // required on some touch devices so
1346
            // ui touch punch is not triggering mousemove
1347
            // which in turn results in pep triggering pointercancel
1348
            e.stopPropagation();
1✔
1349
        });
1350
        parent.find(items).off(Plugin.click).on(Plugin.click, nav, e => this._delegate(e));
163✔
1351
    },
1352

1353
    /**
1354
     * Handler for the "action" items
1355
     *
1356
     * @method _delegate
1357
     * @param {$.Event} e event
1358
     * @private
1359
     */
1360
    // eslint-disable-next-line complexity
1361
    _delegate: function _delegate(e) {
1362
        e.preventDefault();
13✔
1363
        e.stopPropagation();
13✔
1364

1365
        var nav;
1366
        var that = this;
13✔
1367

1368
        if (e.data && e.data.nav) {
13!
1369
            nav = e.data.nav;
×
1370
        }
1371

1372
        // show loader and make sure scroll doesn't jump
1373
        showLoader();
13✔
1374

1375
        var items = '.cms-submenu-edit, .cms-submenu-item a';
13✔
1376
        var el = $(e.target).closest(items);
13✔
1377

1378
        Plugin._hideSettingsMenu(nav);
13✔
1379

1380
        // set switch for subnav entries
1381
        switch (el.attr('data-rel')) {
13!
1382
            // eslint-disable-next-line no-case-declarations
1383
            case 'add':
1384
                const pluginType = el.attr('href').replace('#', '');
2✔
1385
                const showAddForm = el.data('addForm');
2✔
1386

1387
                Plugin._updateUsageCount(pluginType);
2✔
1388
                that.addPlugin(pluginType, el.text(), el.closest('.cms-plugin-picker').data('parentId'), showAddForm);
2✔
1389
                break;
2✔
1390
            case 'ajax_add':
1391
                CMS.API.Toolbar.openAjax({
1✔
1392
                    url: el.attr('href'),
1393
                    post: JSON.stringify(el.data('post')),
1394
                    text: el.data('text'),
1395
                    callback: $.proxy(that.editPluginPostAjax, that),
1396
                    onSuccess: el.data('on-success')
1397
                });
1398
                break;
1✔
1399
            case 'edit':
1400
                that.editPlugin(
1✔
1401
                    Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
1402
                    that.options.plugin_name,
1403
                    that._getPluginBreadcrumbs()
1404
                );
1405
                break;
1✔
1406
            case 'copy-lang':
1407
                that.copyPlugin(that.options, el.attr('data-language'));
1✔
1408
                break;
1✔
1409
            case 'copy':
1410
                if (el.parent().hasClass('cms-submenu-item-disabled')) {
2✔
1411
                    hideLoader();
1✔
1412
                } else {
1413
                    that.copyPlugin();
1✔
1414
                }
1415
                break;
2✔
1416
            case 'cut':
1417
                that.cutPlugin();
1✔
1418
                break;
1✔
1419
            case 'paste':
1420
                hideLoader();
2✔
1421
                if (!el.parent().hasClass('cms-submenu-item-disabled')) {
2✔
1422
                    that.pastePlugin();
1✔
1423
                }
1424
                break;
2✔
1425
            case 'delete':
1426
                that.deletePlugin(
1✔
1427
                    Helpers.updateUrlWithPath(that.options.urls.delete_plugin),
1428
                    that.options.plugin_name,
1429
                    that._getPluginBreadcrumbs()
1430
                );
1431
                break;
1✔
1432
            case 'highlight':
1433
                hideLoader();
×
1434
                // eslint-disable-next-line no-magic-numbers
1435
                window.location.hash = `cms-plugin-${this.options.plugin_id}`;
×
1436
                Plugin._highlightPluginContent(this.options.plugin_id, { seeThrough: true });
×
1437
                e.stopImmediatePropagation();
×
1438
                break;
×
1439
            default:
1440
                hideLoader();
2✔
1441
                CMS.API.Toolbar._delegate(el);
2✔
1442
        }
1443
    },
1444

1445
    /**
1446
     * Sets up keyboard traversing of plugin picker.
1447
     *
1448
     * @method _setupKeyboardTraversing
1449
     * @private
1450
     */
1451
    _setupKeyboardTraversing: function _setupKeyboardTraversing() {
1452
        var dropdown = $('.cms-modal-markup .cms-plugin-picker');
3✔
1453
        const keyDownTraverseEvent = this._getNamepacedEvent(Plugin.keyDown, 'traverse');
3✔
1454

1455
        if (!dropdown.length) {
3✔
1456
            return;
1✔
1457
        }
1458
        // add key events
1459
        $document.off(keyDownTraverseEvent);
2✔
1460
        // istanbul ignore next: not really possible to reproduce focus state in unit tests
1461
        $document.on(keyDownTraverseEvent, function(e) {
1462
            var anchors = dropdown.find('.cms-submenu-item:visible a');
1463
            var index = anchors.index(anchors.filter(':focus'));
1464

1465
            // bind arrow down and tab keys
1466
            if (e.keyCode === KEYS.DOWN || (e.keyCode === KEYS.TAB && !e.shiftKey)) {
1467
                e.preventDefault();
1468
                if (index >= 0 && index < anchors.length - 1) {
1469
                    anchors.eq(index + 1).focus();
1470
                } else {
1471
                    anchors.eq(0).focus();
1472
                }
1473
            }
1474

1475
            // bind arrow up and shift+tab keys
1476
            if (e.keyCode === KEYS.UP || (e.keyCode === KEYS.TAB && e.shiftKey)) {
1477
                e.preventDefault();
1478
                if (anchors.is(':focus')) {
1479
                    anchors.eq(index - 1).focus();
1480
                } else {
1481
                    anchors.eq(anchors.length).focus();
1482
                }
1483
            }
1484
        });
1485
    },
1486

1487
    /**
1488
     * Opens the settings menu for a plugin.
1489
     *
1490
     * @method _showSettingsMenu
1491
     * @private
1492
     * @param {jQuery} nav trigger element
1493
     */
1494
    _showSettingsMenu: function(nav) {
1495
        this._checkIfPasteAllowed();
×
1496

1497
        var dropdown = this.ui.dropdown;
×
1498
        var parents = nav.parentsUntil('.cms-dragarea').last();
×
1499
        var MIN_SCREEN_MARGIN = 10;
×
1500

1501
        nav.addClass('cms-btn-active');
×
1502
        parents.addClass('cms-z-index-9999');
×
1503

1504
        // set visible states
1505
        dropdown.show();
×
1506

1507
        // calculate dropdown positioning
1508
        if (
×
1509
            $window.height() + $window.scrollTop() - nav.offset().top - dropdown.height() <= MIN_SCREEN_MARGIN &&
×
1510
            nav.offset().top - dropdown.height() >= 0
1511
        ) {
1512
            dropdown.removeClass('cms-submenu-dropdown-top').addClass('cms-submenu-dropdown-bottom');
×
1513
        } else {
1514
            dropdown.removeClass('cms-submenu-dropdown-bottom').addClass('cms-submenu-dropdown-top');
×
1515
        }
1516
    },
1517

1518
    /**
1519
     * Filters given plugins list by a query.
1520
     *
1521
     * @method _filterPluginsList
1522
     * @private
1523
     * @param {jQuery} list plugins picker element
1524
     * @param {jQuery} input input, which value to filter plugins with
1525
     * @returns {Boolean|void}
1526
     */
1527
    _filterPluginsList: function _filterPluginsList(list, input) {
1528
        var items = list.find('.cms-submenu-item');
5✔
1529
        var titles = list.find('.cms-submenu-item-title');
5✔
1530
        var query = input.val();
5✔
1531

1532
        // cancel if query is zero
1533
        if (query === '') {
5✔
1534
            items.add(titles).show();
1✔
1535
            return false;
1✔
1536
        }
1537

1538
        var mostRecentItems = list.find('.cms-submenu-item[data-cms-most-used]');
4✔
1539

1540
        mostRecentItems = mostRecentItems.add(mostRecentItems.nextUntil('.cms-submenu-item-title'));
4✔
1541

1542
        var itemsToFilter = items.toArray().map(function(el) {
4✔
1543
            var element = $(el);
72✔
1544

1545
            return {
72✔
1546
                value: element.text(),
1547
                element: element
1548
            };
1549
        });
1550

1551
        var filteredItems = fuzzyFilter(itemsToFilter, query, { key: 'value' });
4✔
1552

1553
        items.hide();
4✔
1554
        filteredItems.forEach(function(item) {
4✔
1555
            item.element.show();
3✔
1556
        });
1557

1558
        // check if a title is matching
1559
        titles.filter(':visible').each(function(index, item) {
4✔
1560
            titles.hide();
1✔
1561
            $(item).nextUntil('.cms-submenu-item-title').show();
1✔
1562
        });
1563

1564
        // always display title of a category
1565
        items.filter(':visible').each(function(index, titleItem) {
4✔
1566
            var item = $(titleItem);
16✔
1567

1568
            if (item.prev().hasClass('cms-submenu-item-title')) {
16✔
1569
                item.prev().show();
2✔
1570
            } else {
1571
                item.prevUntil('.cms-submenu-item-title').last().prev().show();
14✔
1572
            }
1573
        });
1574

1575
        mostRecentItems.hide();
4✔
1576
    },
1577

1578
    /**
1579
     * Toggles collapsable item.
1580
     *
1581
     * @method _toggleCollapsable
1582
     * @private
1583
     * @param {jQuery} el element to toggle
1584
     * @returns {Boolean|void}
1585
     */
1586
    _toggleCollapsable: function toggleCollapsable(el) {
1587
        var that = this;
×
1588
        var id = that._getId(el.parent());
×
1589
        var draggable = el.closest('.cms-draggable');
×
1590
        var items;
1591

1592
        var settings = CMS.settings;
×
1593

1594
        settings.states = settings.states || [];
×
1595

1596
        if (!draggable || !draggable.length) {
×
1597
            return;
×
1598
        }
1599

1600
        // collapsable function and save states
1601
        if (el.hasClass('cms-dragitem-expanded')) {
×
1602
            settings.states.splice($.inArray(id, settings.states), 1);
×
1603
            el
×
1604
                .removeClass('cms-dragitem-expanded')
1605
                .parent()
1606
                .find('> .cms-collapsable-container')
1607
                .addClass('cms-hidden');
1608

1609
            if ($document.data('expandmode')) {
×
1610
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1611
                if (!items.length) {
×
1612
                    return false;
×
1613
                }
1614
                items.each(function() {
×
1615
                    var item = $(this);
×
1616

1617
                    if (item.hasClass('cms-dragitem-expanded')) {
×
1618
                        that._toggleCollapsable(item);
×
1619
                    }
1620
                });
1621
            }
1622
        } else {
1623
            settings.states.push(id);
×
1624
            el
×
1625
                .addClass('cms-dragitem-expanded')
1626
                .parent()
1627
                .find('> .cms-collapsable-container')
1628
                .removeClass('cms-hidden');
1629

1630
            if ($document.data('expandmode')) {
×
1631
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1632
                if (!items.length) {
×
1633
                    return false;
×
1634
                }
1635
                items.each(function() {
×
1636
                    var item = $(this);
×
1637

1638
                    if (!item.hasClass('cms-dragitem-expanded')) {
×
1639
                        that._toggleCollapsable(item);
×
1640
                    }
1641
                });
1642
            }
1643
        }
1644

1645
        this._updatePlaceholderCollapseState();
×
1646

1647
        // make sure structurboard gets updated after expanding
1648
        $document.trigger('resize.sideframe');
×
1649

1650
        // save settings
1651
        Helpers.setSettings(settings);
×
1652
    },
1653

1654
    _updatePlaceholderCollapseState() {
1655
        if (this.options.type !== 'plugin' || !this.options.placeholder_id) {
×
1656
            return;
×
1657
        }
1658

1659
        const pluginsOfCurrentPlaceholder = CMS._plugins
×
1660
            .filter(([, o]) => o.placeholder_id === this.options.placeholder_id && o.type === 'plugin')
×
1661
            .map(([, o]) => o.plugin_id);
×
1662

1663
        const openedPlugins = CMS.settings.states;
×
1664
        const closedPlugins = difference(pluginsOfCurrentPlaceholder, openedPlugins);
×
1665
        const areAllRemainingPluginsLeafs = every(closedPlugins, id => {
×
1666
            return !find(
×
1667
                CMS._plugins,
1668
                ([, o]) => o.placeholder_id === this.options.placeholder_id && o.plugin_parent === id
×
1669
            );
1670
        });
1671
        const el = $(`.cms-dragarea-${this.options.placeholder_id} .cms-dragbar-title`);
×
1672
        var settings = CMS.settings;
×
1673

1674
        if (areAllRemainingPluginsLeafs) {
×
1675
            // meaning that all plugins in current placeholder are expanded
1676
            el.addClass('cms-dragbar-title-expanded');
×
1677

1678
            settings.dragbars = settings.dragbars || [];
×
1679
            settings.dragbars.push(this.options.placeholder_id);
×
1680
        } else {
1681
            el.removeClass('cms-dragbar-title-expanded');
×
1682

1683
            settings.dragbars = settings.dragbars || [];
×
1684
            settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1685
        }
1686
    },
1687

1688
    /**
1689
     * Sets up collabspable event handlers.
1690
     *
1691
     * @method _collapsables
1692
     * @private
1693
     * @returns {Boolean|void}
1694
     */
1695
    _collapsables: function() {
1696
        // one time setup
1697
        var that = this;
153✔
1698

1699
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
153✔
1700
        // cancel here if its not a draggable
1701
        if (!this.ui.draggable.length) {
153✔
1702
            return false;
38✔
1703
        }
1704

1705
        var dragitem = this.ui.draggable.find('> .cms-dragitem');
115✔
1706

1707
        // check which button should be shown for collapsemenu
1708
        var els = this.ui.draggable.find('.cms-dragitem-collapsable');
115✔
1709
        var open = els.filter('.cms-dragitem-expanded');
115✔
1710

1711
        if (els.length === open.length && els.length + open.length !== 0) {
115!
1712
            this.ui.draggable.find('.cms-dragbar-title').addClass('cms-dragbar-title-expanded');
×
1713
        }
1714

1715
        // attach events to draggable
1716
        // debounce here required because on some devices click is not triggered,
1717
        // so we consolidate latest click and touch event to run the collapse only once
1718
        dragitem.find('> .cms-dragitem-text').on(
115✔
1719
            Plugin.touchEnd + ' ' + Plugin.click,
1720
            debounce(function() {
1721
                if (!dragitem.hasClass('cms-dragitem-collapsable')) {
×
1722
                    return;
×
1723
                }
1724
                that._toggleCollapsable(dragitem);
×
1725
            }, 0)
1726
        );
1727
    },
1728

1729
    /**
1730
     * Expands all the collapsables in the given placeholder.
1731
     *
1732
     * @method _expandAll
1733
     * @private
1734
     * @param {jQuery} el trigger element that is a child of a placeholder
1735
     * @returns {Boolean|void}
1736
     */
1737
    _expandAll: function(el) {
1738
        var that = this;
×
1739
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1740

1741
        // cancel if there are no items
1742
        if (!items.length) {
×
1743
            return false;
×
1744
        }
1745
        items.each(function() {
×
1746
            var item = $(this);
×
1747

1748
            if (!item.hasClass('cms-dragitem-expanded')) {
×
1749
                that._toggleCollapsable(item);
×
1750
            }
1751
        });
1752

1753
        el.addClass('cms-dragbar-title-expanded');
×
1754

1755
        var settings = CMS.settings;
×
1756

1757
        settings.dragbars = settings.dragbars || [];
×
1758
        settings.dragbars.push(this.options.placeholder_id);
×
1759
        Helpers.setSettings(settings);
×
1760
    },
1761

1762
    /**
1763
     * Collapses all the collapsables in the given placeholder.
1764
     *
1765
     * @method _collapseAll
1766
     * @private
1767
     * @param {jQuery} el trigger element that is a child of a placeholder
1768
     */
1769
    _collapseAll: function(el) {
1770
        var that = this;
×
1771
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1772

1773
        items.each(function() {
×
1774
            var item = $(this);
×
1775

1776
            if (item.hasClass('cms-dragitem-expanded')) {
×
1777
                that._toggleCollapsable(item);
×
1778
            }
1779
        });
1780

1781
        el.removeClass('cms-dragbar-title-expanded');
×
1782

1783
        var settings = CMS.settings;
×
1784

1785
        settings.dragbars = settings.dragbars || [];
×
1786
        settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1787
        Helpers.setSettings(settings);
×
1788
    },
1789

1790
    /**
1791
     * Gets the id of the element, uses CMS.StructureBoard instance.
1792
     *
1793
     * @method _getId
1794
     * @private
1795
     * @param {jQuery} el element to get id from
1796
     * @returns {String}
1797
     */
1798
    _getId: function(el) {
1799
        return CMS.API.StructureBoard.getId(el);
36✔
1800
    },
1801

1802
    /**
1803
     * Gets the ids of the list of elements, uses CMS.StructureBoard instance.
1804
     *
1805
     * @method _getIds
1806
     * @private
1807
     * @param {jQuery} els elements to get id from
1808
     * @returns {String[]}
1809
     */
1810
    _getIds: function(els) {
1811
        return CMS.API.StructureBoard.getIds(els);
×
1812
    },
1813

1814
    /**
1815
     * Traverses the registry to find plugin parents
1816
     *
1817
     * @method _getPluginBreadcrumbs
1818
     * @returns {Object[]} array of breadcrumbs in `{ url, title }` format
1819
     * @private
1820
     */
1821
    _getPluginBreadcrumbs: function _getPluginBreadcrumbs() {
1822
        var breadcrumbs = [];
6✔
1823

1824
        breadcrumbs.unshift({
6✔
1825
            title: this.options.plugin_name,
1826
            url: this.options.urls.edit_plugin
1827
        });
1828

1829
        var findParentPlugin = function(id) {
6✔
1830
            return $.grep(CMS._plugins || [], function(pluginOptions) {
6✔
1831
                return pluginOptions[0] === 'cms-plugin-' + id;
10✔
1832
            })[0];
1833
        };
1834

1835
        var id = this.options.plugin_parent;
6✔
1836
        var data;
1837

1838
        while (id && id !== 'None') {
6✔
1839
            data = findParentPlugin(id);
6✔
1840

1841
            if (!data) {
6✔
1842
                break;
1✔
1843
            }
1844

1845
            breadcrumbs.unshift({
5✔
1846
                title: data[1].plugin_name,
1847
                url: data[1].urls.edit_plugin
1848
            });
1849
            id = data[1].plugin_parent;
5✔
1850
        }
1851

1852
        return breadcrumbs;
6✔
1853
    }
1854
});
1855

1856
Plugin.click = 'click.cms.plugin';
1✔
1857
Plugin.pointerUp = 'pointerup.cms.plugin';
1✔
1858
Plugin.pointerDown = 'pointerdown.cms.plugin';
1✔
1859
Plugin.pointerOverAndOut = 'pointerover.cms.plugin pointerout.cms.plugin';
1✔
1860
Plugin.doubleClick = 'dblclick.cms.plugin';
1✔
1861
Plugin.keyUp = 'keyup.cms.plugin';
1✔
1862
Plugin.keyDown = 'keydown.cms.plugin';
1✔
1863
Plugin.mouseEvents = 'mousedown.cms.plugin mousemove.cms.plugin mouseup.cms.plugin';
1✔
1864
Plugin.touchStart = 'touchstart.cms.plugin';
1✔
1865
Plugin.touchEnd = 'touchend.cms.plugin';
1✔
1866

1867
/**
1868
 * Updates plugin data in CMS._plugins / CMS._instances or creates new
1869
 * plugin instances if they didn't exist
1870
 *
1871
 * @method _updateRegistry
1872
 * @private
1873
 * @static
1874
 * @param {Object[]} plugins plugins data
1875
 */
1876
Plugin._updateRegistry = function _updateRegistry(plugins) {
1✔
1877
    plugins.forEach(pluginData => {
×
1878
        const pluginContainer = `cms-plugin-${pluginData.plugin_id}`;
×
1879
        const pluginIndex = findIndex(CMS._plugins, ([pluginStr]) => pluginStr === pluginContainer);
×
1880

1881
        if (pluginIndex === -1) {
×
1882
            CMS._plugins.push([pluginContainer, pluginData]);
×
1883
            CMS._instances.push(new Plugin(pluginContainer, pluginData));
×
1884
        } else {
1885
            Plugin.aliasPluginDuplicatesMap[pluginData.plugin_id] = false;
×
1886
            CMS._plugins[pluginIndex] = [pluginContainer, pluginData];
×
1887
            CMS._instances[pluginIndex] = new Plugin(pluginContainer, pluginData);
×
1888
        }
1889
    });
1890
};
1891

1892
/**
1893
 * Hides the opened settings menu. By default looks for any open ones.
1894
 *
1895
 * @method _hideSettingsMenu
1896
 * @static
1897
 * @private
1898
 * @param {jQuery} [navEl] element representing the subnav trigger
1899
 */
1900
Plugin._hideSettingsMenu = function(navEl) {
1✔
1901
    var nav = navEl || $('.cms-submenu-btn.cms-btn-active');
20✔
1902

1903
    if (!nav.length) {
20!
1904
        return;
20✔
1905
    }
1906
    nav.removeClass('cms-btn-active');
×
1907

1908
    // set correct active state
1909
    nav.closest('.cms-draggable').data('active', false);
×
1910
    $('.cms-z-index-9999').removeClass('cms-z-index-9999');
×
1911

1912
    nav.siblings('.cms-submenu-dropdown').hide();
×
1913
    nav.siblings('.cms-quicksearch').hide();
×
1914
    // reset search
1915
    nav.siblings('.cms-quicksearch').find('input').val('').trigger(Plugin.keyUp).blur();
×
1916

1917
    // reset relativity
1918
    $('.cms-dragbar').css('position', '');
×
1919
};
1920

1921
/**
1922
 * Initialises handlers that affect all plugins and don't make sense
1923
 * in context of each own plugin instance, e.g. listening for a click on a document
1924
 * to hide plugin settings menu should only be applied once, and not every time
1925
 * CMS.Plugin is instantiated.
1926
 *
1927
 * @method _initializeGlobalHandlers
1928
 * @static
1929
 * @private
1930
 */
1931
Plugin._initializeGlobalHandlers = function _initializeGlobalHandlers() {
1✔
1932
    var timer;
1933
    var clickCounter = 0;
6✔
1934

1935
    Plugin._updateClipboard();
6✔
1936

1937
    // Structureboard initialized too late
1938
    setTimeout(function() {
6✔
1939
        var pluginData = {};
6✔
1940
        var html = '';
6✔
1941

1942
        if (clipboardDraggable.length) {
6✔
1943
            pluginData = find(
5✔
1944
                CMS._plugins,
1945
                ([desc]) => desc === `cms-plugin-${CMS.API.StructureBoard.getId(clipboardDraggable)}`
10✔
1946
            )[1];
1947
            html = clipboardDraggable.parent().html();
5✔
1948
        }
1949
        if (CMS.API && CMS.API.Clipboard) {
6!
1950
            CMS.API.Clipboard.populate(html, pluginData);
6✔
1951
        }
1952
    }, 0);
1953

1954
    $document
6✔
1955
        .off(Plugin.pointerUp)
1956
        .off(Plugin.keyDown)
1957
        .off(Plugin.keyUp)
1958
        .off(Plugin.click, '.cms-plugin a, a:has(.cms-plugin), a.cms-plugin')
1959
        .on(Plugin.pointerUp, function() {
1960
            // call it as a static method, because otherwise we trigger it the
1961
            // amount of times CMS.Plugin is instantiated,
1962
            // which does not make much sense.
1963
            Plugin._hideSettingsMenu();
×
1964
        })
1965
        .on(Plugin.keyDown, function(e) {
1966
            if (e.keyCode === KEYS.SHIFT) {
26!
1967
                $document.data('expandmode', true);
×
1968
                try {
×
1969
                    $('.cms-plugin:hover').last().trigger('mouseenter');
×
1970
                    $('.cms-dragitem:hover').last().trigger('mouseenter');
×
1971
                } catch (err) {}
1972
            }
1973
        })
1974
        .on(Plugin.keyUp, function(e) {
1975
            if (e.keyCode === KEYS.SHIFT) {
23!
1976
                $document.data('expandmode', false);
×
1977
                try {
×
1978
                    $(':hover').trigger('mouseleave');
×
1979
                } catch (err) {}
1980
            }
1981
        })
1982
        .on(Plugin.click, '.cms-plugin a, a:has(.cms-plugin), a.cms-plugin', function(e) {
1983
            var DOUBLECLICK_DELAY = 300;
×
1984

1985
            // prevents single click from messing up the edit call
1986
            // don't go to the link if there is custom js attached to it
1987
            // or if it's clicked along with shift, ctrl, cmd
1988
            if (e.shiftKey || e.ctrlKey || e.metaKey || e.isDefaultPrevented()) {
×
1989
                return;
×
1990
            }
1991
            e.preventDefault();
×
1992
            if (++clickCounter === 1) {
×
1993
                timer = setTimeout(function() {
×
1994
                    var anchor = $(e.target).closest('a');
×
1995

1996
                    clickCounter = 0;
×
1997
                    window.open(anchor.attr('href'), anchor.attr('target') || '_self');
×
1998
                }, DOUBLECLICK_DELAY);
1999
            } else {
2000
                clearTimeout(timer);
×
2001
                clickCounter = 0;
×
2002
            }
2003
        });
2004

2005
    // have to delegate here because there might be plugins that
2006
    // have their content replaced by something dynamic. in case that tool
2007
    // copies the classes - double click to edit would still work
2008
    // also - do not try to highlight render_model_blocks, only actual plugins
2009
    $document.on(Plugin.click, '.cms-plugin:not([class*=cms-render-model])', Plugin._clickToHighlightHandler);
6✔
2010
    $document.on(`${Plugin.pointerOverAndOut} ${Plugin.touchStart}`, '.cms-plugin', function(e) {
6✔
2011
        // required for both, click and touch
2012
        // otherwise propagation won't work to the nested plugin
2013

2014
        e.stopPropagation();
×
NEW
2015
        const pluginContainer = $(e.target).closest('.cms-plugin:not(.cms-edit-disabled)');
×
2016
        const allOptions = pluginContainer.data('cms');
×
2017

2018
        if (!allOptions || !allOptions.length) {
×
2019
            return;
×
2020
        }
2021

2022
        const options = allOptions[0];
×
2023

2024
        if (e.type === 'touchstart') {
×
2025
            CMS.API.Tooltip._forceTouchOnce();
×
2026
        }
2027
        var name = options.plugin_name;
×
2028
        var id = options.plugin_id;
×
2029
        var type = options.type;
×
2030

2031
        if (type === 'generic') {
×
2032
            return;
×
2033
        }
2034
        var placeholderId = CMS.API.StructureBoard.getId($(`.cms-draggable-${id}`).closest('.cms-dragarea'));
×
2035
        var placeholder = $('.cms-placeholder-' + placeholderId);
×
2036

2037
        if (placeholder.length && placeholder.data('cms')) {
×
2038
            name = placeholder.data('cms').name + ': ' + name;
×
2039
        }
2040

2041
        CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
2042
    });
2043

2044
    $document.on(Plugin.click, '.cms-dragarea-static .cms-dragbar', e => {
6✔
2045
        const placeholder = $(e.target).closest('.cms-dragarea');
×
2046

2047
        if (placeholder.hasClass('cms-dragarea-static-expanded') && e.isDefaultPrevented()) {
×
2048
            return;
×
2049
        }
2050

2051
        placeholder.toggleClass('cms-dragarea-static-expanded');
×
2052
    });
2053

2054
    $window.on('blur.cms', () => {
6✔
2055
        $document.data('expandmode', false);
6✔
2056
    });
2057
};
2058

2059
/**
2060
 * @method _isContainingMultiplePlugins
2061
 * @param {jQuery} node to check
2062
 * @static
2063
 * @private
2064
 * @returns {Boolean}
2065
 */
2066
Plugin._isContainingMultiplePlugins = function _isContainingMultiplePlugins(node) {
1✔
2067
    var currentData = node.data('cms');
130✔
2068

2069
    // istanbul ignore if
2070
    if (!currentData) {
130✔
2071
        throw new Error('Provided node is not a cms plugin.');
2072
    }
2073

2074
    var pluginIds = currentData.map(function(pluginData) {
130✔
2075
        return pluginData.plugin_id;
131✔
2076
    });
2077

2078
    if (pluginIds.length > 1) {
130✔
2079
        // another plugin already lives on the same node
2080
        // this only works because the plugins are rendered from
2081
        // the bottom to the top (leaf to root)
2082
        // meaning the deepest plugin is always first
2083
        return true;
1✔
2084
    }
2085

2086
    return false;
129✔
2087
};
2088

2089
/**
2090
 * Shows and immediately fades out a success notification (when
2091
 * plugin was successfully moved.
2092
 *
2093
 * @method _highlightPluginStructure
2094
 * @private
2095
 * @static
2096
 * @param {jQuery} el draggable element
2097
 */
2098
// eslint-disable-next-line no-magic-numbers
2099
Plugin._highlightPluginStructure = function _highlightPluginStructure(
1✔
2100
    el,
2101
    // eslint-disable-next-line no-magic-numbers
2102
    { successTimeout = 200, delay = 1500, seeThrough = false }
×
2103
) {
2104
    const tpl = $(`
×
2105
        <div class="cms-dragitem-success ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}">
×
2106
        </div>
2107
    `);
2108

2109
    el.addClass('cms-draggable-success').append(tpl);
×
2110
    // start animation
2111
    if (successTimeout) {
×
2112
        setTimeout(() => {
×
2113
            tpl.fadeOut(successTimeout, function() {
×
2114
                $(this).remove();
×
2115
                el.removeClass('cms-draggable-success');
×
2116
            });
2117
        }, delay);
2118
    }
2119
    // make sure structurboard gets updated after success
2120
    $(Helpers._getWindow()).trigger('resize.sideframe');
×
2121
};
2122

2123
/**
2124
 * Highlights plugin in content mode
2125
 *
2126
 * @method _highlightPluginContent
2127
 * @private
2128
 * @static
2129
 * @param {String|Number} pluginId
2130
 */
2131
Plugin._highlightPluginContent = function _highlightPluginContent(
1✔
2132
    pluginId,
2133
    // eslint-disable-next-line no-magic-numbers
2134
    { successTimeout = 200, seeThrough = false, delay = 1500, prominent = false } = {}
5✔
2135
) {
2136
    var coordinates = {};
1✔
2137
    var positions = [];
1✔
2138
    var OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO = 0.2;
1✔
2139

2140
    $('.cms-plugin-' + pluginId).each(function() {
1✔
2141
        var el = $(this);
1✔
2142
        var offset = el.offset();
1✔
2143
        var ml = parseInt(el.css('margin-left'), 10);
1✔
2144
        var mr = parseInt(el.css('margin-right'), 10);
1✔
2145
        var mt = parseInt(el.css('margin-top'), 10);
1✔
2146
        var mb = parseInt(el.css('margin-bottom'), 10);
1✔
2147
        var width = el.outerWidth();
1✔
2148
        var height = el.outerHeight();
1✔
2149

2150
        if (width === 0 && height === 0) {
1!
2151
            return;
×
2152
        }
2153

2154
        if (isNaN(ml)) {
1!
2155
            ml = 0;
×
2156
        }
2157
        if (isNaN(mr)) {
1!
2158
            mr = 0;
×
2159
        }
2160
        if (isNaN(mt)) {
1!
2161
            mt = 0;
×
2162
        }
2163
        if (isNaN(mb)) {
1!
2164
            mb = 0;
×
2165
        }
2166

2167
        positions.push({
1✔
2168
            x1: offset.left - ml,
2169
            x2: offset.left + width + mr,
2170
            y1: offset.top - mt,
2171
            y2: offset.top + height + mb
2172
        });
2173
    });
2174

2175
    if (positions.length === 0) {
1!
2176
        return;
×
2177
    }
2178

2179
    // turns out that offset calculation will be off by toolbar height if
2180
    // position is set to "relative" on html element.
2181
    var html = $('html');
1✔
2182
    var htmlMargin = html.css('position') === 'relative' ? parseInt($('html').css('margin-top'), 10) : 0;
1!
2183

2184
    coordinates.left = Math.min(...positions.map(pos => pos.x1));
1✔
2185
    coordinates.top = Math.min(...positions.map(pos => pos.y1)) - htmlMargin;
1✔
2186
    coordinates.width = Math.max(...positions.map(pos => pos.x2)) - coordinates.left;
1✔
2187
    coordinates.height = Math.max(...positions.map(pos => pos.y2)) - coordinates.top - htmlMargin;
1✔
2188

2189
    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);
1✔
2190

2191
    $(
1✔
2192
        `
2193
        <div class="
2194
            cms-plugin-overlay
2195
            cms-dragitem-success
2196
            cms-plugin-overlay-${pluginId}
2197
            ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}
1!
2198
            ${prominent ? 'cms-plugin-overlay-prominent' : ''}
1!
2199
        "
2200
            data-success-timeout="${successTimeout}"
2201
        >
2202
        </div>
2203
    `
2204
    )
2205
        .css(coordinates)
2206
        .css({
2207
            zIndex: 9999
2208
        })
2209
        .appendTo($('body'));
2210

2211
    if (successTimeout) {
1!
2212
        setTimeout(() => {
1✔
2213
            $(`.cms-plugin-overlay-${pluginId}`).fadeOut(successTimeout, function() {
1✔
2214
                $(this).remove();
1✔
2215
            });
2216
        }, delay);
2217
    }
2218
};
2219

2220
Plugin._clickToHighlightHandler = function _clickToHighlightHandler(e) {
1✔
2221
    if (CMS.settings.mode !== 'structure') {
×
2222
        return;
×
2223
    }
2224
    e.preventDefault();
×
2225
    e.stopPropagation();
×
2226
    // FIXME refactor into an object
2227
    CMS.API.StructureBoard._showAndHighlightPlugin(200, true); // eslint-disable-line no-magic-numbers
×
2228
};
2229

2230
Plugin._removeHighlightPluginContent = function(pluginId) {
1✔
2231
    $(`.cms-plugin-overlay-${pluginId}[data-success-timeout=0]`).remove();
×
2232
};
2233

2234
Plugin.aliasPluginDuplicatesMap = {};
1✔
2235
Plugin.staticPlaceholderDuplicatesMap = {};
1✔
2236

2237
// istanbul ignore next
2238
Plugin._initializeTree = function _initializeTree() {
2239
    CMS._plugins = uniqWith(CMS._plugins, ([x], [y]) => x === y);
2240
    CMS._instances = CMS._plugins.map(function(args) {
2241
        return new CMS.Plugin(args[0], args[1]);
2242
    });
2243

2244
    // return the cms plugin instances just created
2245
    return CMS._instances;
2246
};
2247

2248
Plugin._updateClipboard = function _updateClipboard() {
1✔
2249
    clipboardDraggable = $('.cms-draggable-from-clipboard:first');
7✔
2250
};
2251

2252
Plugin._updateUsageCount = function _updateUsageCount(pluginType) {
1✔
2253
    var currentValue = pluginUsageMap[pluginType] || 0;
2✔
2254

2255
    pluginUsageMap[pluginType] = currentValue + 1;
2✔
2256

2257
    if (Helpers._isStorageSupported) {
2!
UNCOV
2258
        localStorage.setItem('cms-plugin-usage', JSON.stringify(pluginUsageMap));
×
2259
    }
2260
};
2261

2262
Plugin._removeAddPluginPlaceholder = function removeAddPluginPlaceholder() {
1✔
2263
    // this can't be cached since they are created and destroyed all over the place
2264
    $('.cms-add-plugin-placeholder').remove();
10✔
2265
};
2266

2267
Plugin._refreshPlugins = function refreshPlugins() {
1✔
2268
    Plugin.aliasPluginDuplicatesMap = {};
4✔
2269
    Plugin.staticPlaceholderDuplicatesMap = {};
4✔
2270
    CMS._plugins = uniqWith(CMS._plugins, isEqual);
4✔
2271

2272
    CMS._instances.forEach(instance => {
4✔
2273
        if (instance.options.type === 'placeholder') {
5✔
2274
            instance._setupUI(`cms-placeholder-${instance.options.placeholder_id}`);
2✔
2275
            instance._ensureData();
2✔
2276
            instance.ui.container.data('cms', instance.options);
2✔
2277
            instance._setPlaceholder();
2✔
2278
        }
2279
    });
2280

2281
    CMS._instances.forEach(instance => {
4✔
2282
        if (instance.options.type === 'plugin') {
5✔
2283
            instance._setupUI(`cms-plugin-${instance.options.plugin_id}`);
2✔
2284
            instance._ensureData();
2✔
2285
            instance.ui.container.data('cms').push(instance.options);
2✔
2286
            instance._setPluginContentEvents();
2✔
2287
        }
2288
    });
2289

2290
    CMS._plugins.forEach(([type, opts]) => {
4✔
2291
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2292
            const instance = find(
8✔
2293
                CMS._instances,
2294
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
13✔
2295
            );
2296

2297
            if (instance) {
8✔
2298
                // update
2299
                instance._setupUI(type);
1✔
2300
                instance._ensureData();
1✔
2301
                instance.ui.container.data('cms').push(instance.options);
1✔
2302
                instance._setGeneric();
1✔
2303
            } else {
2304
                // create
2305
                CMS._instances.push(new Plugin(type, opts));
7✔
2306
            }
2307
        }
2308
    });
2309
};
2310

2311
Plugin._getPluginById = function(id) {
1✔
2312
    return find(CMS._instances, ({ options }) => options.type === 'plugin' && Number(options.plugin_id) === Number(id));
20!
2313
};
2314

2315
Plugin._updatePluginPositions = function(placeholder_id) {
1✔
2316
    // TODO can this be done in pure js? keep a tree model of the structure
2317
    // on the placeholder and update things there?
2318
    const plugins = $(`.cms-dragarea-${placeholder_id} .cms-draggable`).toArray();
10✔
2319

2320
    plugins.forEach((element, index) => {
10✔
2321
        const pluginId = CMS.API.StructureBoard.getId($(element));
20✔
2322
        const instance = Plugin._getPluginById(pluginId);
20✔
2323

2324
        if (!instance) {
20!
2325
            return;
20✔
2326
        }
2327

UNCOV
2328
        instance.options.position = index + 1;
×
2329
    });
2330
};
2331

2332
Plugin._recalculatePluginPositions = function(action, data) {
1✔
UNCOV
2333
    if (action === 'MOVE') {
×
2334
        // le sigh - recalculate all placeholders cause we don't know from where the
2335
        // plugin was moved from
UNCOV
2336
        filter(CMS._instances, ({ options }) => options.type === 'placeholder')
×
UNCOV
2337
            .map(({ options }) => options.placeholder_id)
×
UNCOV
2338
            .forEach(placeholder_id => Plugin._updatePluginPositions(placeholder_id));
×
UNCOV
2339
    } else if (data.placeholder_id) {
×
UNCOV
2340
        Plugin._updatePluginPositions(data.placeholder_id);
×
2341
    }
2342
};
2343

2344
// shorthand for jQuery(document).ready();
2345
$(Plugin._initializeGlobalHandlers);
1✔
2346

2347
export default Plugin;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc