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

divio / django-cms / #29415

10 Feb 2025 08:48PM UTC coverage: 75.318% (-1.0%) from 76.342%
#29415

push

travis-ci

web-flow
Merge 36d7bf5bf into 733c377ab

1058 of 1599 branches covered (66.17%)

43 of 86 new or added lines in 2 files covered. (50.0%)

272 existing lines in 2 files now uncovered.

2545 of 3379 relevant lines covered (75.32%)

26.47 hits per line

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

59.3
/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
                    const templateStart = $(items[0]);
137✔
156
                    const className = templateStart.attr('class').replace('cms-plugin-start', '');
137✔
157
                    const position = templateStart.attr('data-cms-position');
137✔
158
                    let 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
                    itemContents.first().addClass(`cms-plugin-first`).attr('data-cms-position', position);
137✔
179
                    itemContents.last().addClass(`cms-plugin-last`).attr('data-cms-position', position);
137✔
180
                    return itemContents;
137✔
181
                })
182
                .filter(v => v.length);
137✔
183

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

340
    _dblClickToEditHandler: function _dblClickToEditHandler(e) {
341
        var that = this;
×
UNCOV
342
        var disabled = $(e.currentTarget).closest('.cms-drag-disabled');
×
343

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

UNCOV
347
        if (!disabled.length) {
×
UNCOV
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();
×
UNCOV
369
                $('.cms-dragitem-success').remove();
×
UNCOV
370
                $('.cms-draggable-success').removeClass('cms-draggable-success');
×
UNCOV
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();
×
UNCOV
379
                if (this.ui.draggable && this.ui.draggable.length) {
×
UNCOV
380
                    this.ui.draggable.find('.cms-dragitem-success').remove();
×
UNCOV
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
            $document
129✔
388
                .off(pluginDoubleClickEvent, `.cms-plugin-${this.options.plugin_id}`)
389
                .on(
390
                    pluginDoubleClickEvent,
391
                    `.cms-plugin-${this.options.plugin_id}`,
392
                    this._dblClickToEditHandler.bind(this)
393
                );
394
        }
395
    },
396

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

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

414
        // adds edit tooltip
415
        this.ui.container
24✔
416
            .off(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart)
417
            .on(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart, function(e) {
418
                if (e.type !== 'touchstart') {
×
419
                    e.stopPropagation();
×
420
                }
421
                var name = that.options.plugin_name;
×
UNCOV
422
                var id = that.options.plugin_id;
×
423

UNCOV
424
                CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
425
            });
426
    },
427

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

443
        if (!clipboardDraggable.length) {
151✔
444
            pasteItem.addClass('cms-submenu-item-disabled');
86✔
445
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
86✔
446
            pasteItem.find('.cms-submenu-item-paste-tooltip-empty').css('display', 'block');
86✔
447
            return false;
86✔
448
        }
449

450
        if (this.ui.draggable && this.ui.draggable.hasClass('cms-draggable-disabled')) {
65✔
451
            pasteItem.addClass('cms-submenu-item-disabled');
45✔
452
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
45✔
453
            pasteItem.find('.cms-submenu-item-paste-tooltip-disabled').css('display', 'block');
45✔
454
            return false;
45✔
455
        }
456

457
        var bounds = this.options.plugin_restriction;
20✔
458

459
        if (clipboardDraggable.data('cms')) {
20!
460
            var clipboardPluginData = clipboardDraggable.data('cms');
20✔
461
            var type = clipboardPluginData.plugin_type;
20✔
462
            var parent_bounds = $.grep(clipboardPluginData.plugin_parent_restriction, function(restriction) {
20✔
463
                // special case when PlaceholderPlugin has a parent restriction named "0"
464
                return restriction !== '0';
20✔
465
            });
466
            var currentPluginType = this.options.plugin_type;
20✔
467

468
            if (
20✔
469
                (bounds.length && $.inArray(type, bounds) === -1) ||
60!
470
                (parent_bounds.length && $.inArray(currentPluginType, parent_bounds) === -1)
471
            ) {
472
                pasteItem.addClass('cms-submenu-item-disabled');
15✔
473
                pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
15✔
474
                pasteItem.find('.cms-submenu-item-paste-tooltip-restricted').css('display', 'block');
15✔
475
                return false;
15✔
476
            }
477
        } else {
UNCOV
478
            return false;
×
479
        }
480

481
        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
5✔
482
        pasteItem.removeClass('cms-submenu-item-disabled');
5✔
483

484
        return true;
5✔
485
    },
486

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

507
        if (parent) {
4✔
508
            params.plugin_parent = parent;
2✔
509
        }
510
        var url = this.options.urls.add_plugin + '?' + $.param(params);
4✔
511

512
        const modal = new Modal({
4✔
513
            onClose: this.options.onClose || false,
7✔
514
            redirectOnClose: this.options.redirectOnClose || false
7✔
515
        });
516

517
        if (showAddForm) {
4✔
518
            modal.open({
3✔
519
                url: url,
520
                title: name
521
            });
522
        } else {
523
            // Also open the modal but without the content. Instead create a form and immediately submit it.
524
            modal.open({
1✔
525
                url: '#',
526
                title: name
527
            });
528
            if (modal.ui) {
1!
529
                // Hide the plugin type selector modal if it's open
530
                modal.ui.modal.hide();
1✔
531
            }
532
            const contents = modal.ui.frame.find('iframe').contents();
1✔
533
            const body = contents.find('body');
1✔
534

535
            body.append(`<form method="post" action="${url}" style="display: none;">
1✔
536
                <input type="hidden" name="csrfmiddlewaretoken" value="${CMS.config.csrf}"></form>`);
537
            body.find('form').submit();
1✔
538
        }
539
        this.modal = modal;
4✔
540

541
        Helpers.removeEventListener('modal-closed.add-plugin');
4✔
542
        Helpers.addEventListener('modal-closed.add-plugin', (e, { instance }) => {
4✔
543
            if (instance !== modal) {
1!
544
                return;
×
545
            }
546
            Plugin._removeAddPluginPlaceholder();
1✔
547
        });
548
    },
549

550
    _getPluginAddPosition: function() {
UNCOV
551
        if (this.options.type === 'placeholder') {
×
UNCOV
552
            return $(`.cms-dragarea-${this.options.placeholder_id} .cms-draggable`).length + 1;
×
553
        }
554

555
        // assume plugin now
556
        // would prefer to get the information from the tree, but the problem is that the flat data
557
        // isn't sorted by position
UNCOV
558
        const maybeChildren = this.ui.draggable.find('.cms-draggable');
×
559

UNCOV
560
        if (maybeChildren.length) {
×
UNCOV
561
            const lastChild = maybeChildren.last();
×
562

UNCOV
563
            const lastChildInstance = Plugin._getPluginById(this._getId(lastChild));
×
564

UNCOV
565
            return lastChildInstance.options.position + 1;
×
566
        }
567

UNCOV
568
        return this.options.position + 1;
×
569
    },
570

571
    /**
572
     * Opens the modal for editing a plugin.
573
     *
574
     * @method editPlugin
575
     * @param {String} url editing url
576
     * @param {String} name Name of the plugin, e.g. "Column"
577
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
578
     *     each item is `{ title: 'string': url: 'string' }`
579
     */
580
    editPlugin: function(url, name, breadcrumb) {
581
        // trigger modal window
582
        var modal = new Modal({
3✔
583
            onClose: this.options.onClose || false,
6✔
584
            redirectOnClose: this.options.redirectOnClose || false
6✔
585
        });
586

587
        this.modal = modal;
3✔
588

589
        Helpers.removeEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin');
3✔
590
        Helpers.addEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin', (e, { instance }) => {
3✔
591
            if (instance === modal) {
1!
592
                // cannot be cached
593
                Plugin._removeAddPluginPlaceholder();
1✔
594
            }
595
        });
596
        modal.open({
3✔
597
            url: url,
598
            title: name,
599
            breadcrumbs: breadcrumb,
600
            width: 850
601
        });
602
    },
603

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

624
        // set correct options (don't mutate them)
625
        var options = $.extend({}, opts || this.options);
8✔
626
        var sourceLanguage = source_language;
8✔
627
        let copyingFromLanguage = false;
8✔
628

629
        if (sourceLanguage) {
8✔
630
            copyingFromLanguage = true;
1✔
631
            options.target = options.placeholder_id;
1✔
632
            options.plugin_id = '';
1✔
633
            options.parent = '';
1✔
634
        } else {
635
            sourceLanguage = CMS.config.request.language;
7✔
636
        }
637

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

667
                // trigger error
668
                CMS.API.Messages.open({
3✔
669
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
670
                    error: true
671
                });
672
            }
673
        };
674

675
        $.ajax(request);
8✔
676
    },
677

678
    /**
679
     * Essentially clears clipboard and moves plugin to a clipboard
680
     * placholder through `movePlugin`.
681
     *
682
     * @method cutPlugin
683
     * @returns {Boolean|void}
684
     */
685
    cutPlugin: function() {
686
        // if cut is once triggered, prevent additional actions
687
        if (CMS.API.locked) {
9✔
688
            return false;
1✔
689
        }
690
        CMS.API.locked = true;
8✔
691

692
        var that = this;
8✔
693
        var data = {
8✔
694
            placeholder_id: CMS.config.clipboard.id,
695
            plugin_id: this.options.plugin_id,
696
            plugin_parent: '',
697
            target_language: CMS.config.request.language,
698
            csrfmiddlewaretoken: CMS.config.csrf
699
        };
700

701
        // move plugin
702
        $.ajax({
8✔
703
            type: 'POST',
704
            url: Helpers.updateUrlWithPath(that.options.urls.move_plugin),
705
            data: data,
706
            success: function(response) {
707
                CMS.API.locked = false;
4✔
708
                CMS.API.Messages.open({
4✔
709
                    message: CMS.config.lang.success
710
                });
711
                CMS.API.StructureBoard.invalidateState('CUT', $.extend({}, data, response));
4✔
712
                hideLoader();
4✔
713
            },
714
            error: function(jqXHR) {
715
                CMS.API.locked = false;
3✔
716
                var msg = CMS.config.lang.error;
3✔
717

718
                // trigger error
719
                CMS.API.Messages.open({
3✔
720
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
721
                    error: true
722
                });
723
                hideLoader();
3✔
724
            }
725
        });
726
    },
727

728
    /**
729
     * Method is called when you click on the paste button on the plugin.
730
     * Uses existing solution of `copyPlugin(options)`
731
     *
732
     * @method pastePlugin
733
     */
734
    pastePlugin: function() {
735
        var id = this._getId(clipboardDraggable);
5✔
736
        var eventData = {
5✔
737
            id: id
738
        };
739

740
        const clipboardDraggableClone = clipboardDraggable.clone(true, true);
5✔
741

742
        clipboardDraggableClone.appendTo(this.ui.draggables);
5✔
743
        if (this.options.plugin_id) {
5✔
744
            StructureBoard.actualizePluginCollapseStatus(this.options.plugin_id);
4✔
745
        }
746
        this.ui.draggables.trigger('cms-structure-update', [eventData]);
5✔
747
        clipboardDraggableClone.trigger('cms-paste-plugin-update', [eventData]);
5✔
748
    },
749

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

769
        // set correct options
770
        var options = opts || this.options;
11✔
771

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

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

777
        // cancel here if we have no placeholder id
778
        if (placeholder_id === false) {
11✔
779
            return false;
1✔
780
        }
781
        var pluginParentElement = dragitem.parent().closest('.cms-draggable');
10✔
782
        var plugin_parent = this._getId(pluginParentElement);
10✔
783

784
        // gather the data for ajax request
785
        var data = {
10✔
786
            plugin_id: options.plugin_id,
787
            plugin_parent: plugin_parent || '',
20✔
788
            target_language: CMS.config.request.language,
789
            csrfmiddlewaretoken: CMS.config.csrf,
790
            move_a_copy: options.move_a_copy
791
        };
792

793
        if (Number(placeholder_id) === Number(options.placeholder_id)) {
10!
794
            Plugin._updatePluginPositions(options.placeholder_id);
10✔
795
        } else {
UNCOV
796
            data.placeholder_id = placeholder_id;
×
797

UNCOV
798
            Plugin._updatePluginPositions(placeholder_id);
×
UNCOV
799
            Plugin._updatePluginPositions(options.placeholder_id);
×
800
        }
801

802
        var position = this.options.position;
10✔
803

804
        data.target_position = position;
10✔
805

806
        showLoader();
10✔
807

808
        $.ajax({
10✔
809
            type: 'POST',
810
            url: Helpers.updateUrlWithPath(options.urls.move_plugin),
811
            data: data,
812
            success: function(response) {
813
                CMS.API.StructureBoard.invalidateState(
4✔
814
                    data.move_a_copy ? 'PASTE' : 'MOVE',
4!
815
                    $.extend({}, data, { placeholder_id: placeholder_id }, response)
816
                );
817

818
                // enable actions again
819
                CMS.API.locked = false;
4✔
820
                hideLoader();
4✔
821
            },
822
            error: function(jqXHR) {
823
                CMS.API.locked = false;
4✔
824
                var msg = CMS.config.lang.error;
4✔
825

826
                // trigger error
827
                CMS.API.Messages.open({
4✔
828
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
5✔
829
                    error: true
830
                });
831
                hideLoader();
4✔
832
            }
833
        });
834
    },
835

836
    /**
837
     * Changes the settings attributes on an initialised plugin.
838
     *
839
     * @method _setSettings
840
     * @param {Object} oldSettings current settings
841
     * @param {Object} newSettings new settings to be applied
842
     * @private
843
     */
844
    _setSettings: function _setSettings(oldSettings, newSettings) {
UNCOV
845
        var settings = $.extend(true, {}, oldSettings, newSettings);
×
UNCOV
846
        var plugin = $('.cms-plugin-' + settings.plugin_id);
×
UNCOV
847
        var draggable = $('.cms-draggable-' + settings.plugin_id);
×
848

849
        // set new setting on instance and plugin data
UNCOV
850
        this.options = settings;
×
UNCOV
851
        if (plugin.length) {
×
UNCOV
852
            var index = plugin.data('cms').findIndex(function(pluginData) {
×
UNCOV
853
                return pluginData.plugin_id === settings.plugin_id;
×
854
            });
855

UNCOV
856
            plugin.each(function() {
×
UNCOV
857
                $(this).data('cms')[index] = settings;
×
858
            });
859
        }
UNCOV
860
        if (draggable.length) {
×
UNCOV
861
            draggable.data('cms', settings);
×
862
        }
863
    },
864

865
    /**
866
     * Opens a modal to delete a plugin.
867
     *
868
     * @method deletePlugin
869
     * @param {String} url admin url for deleting a page
870
     * @param {String} name plugin name, e.g. "Column"
871
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
872
     *     each item is `{ title: 'string': url: 'string' }`
873
     */
874
    deletePlugin: function(url, name, breadcrumb) {
875
        // trigger modal window
876
        var modal = new Modal({
2✔
877
            onClose: this.options.onClose || false,
4✔
878
            redirectOnClose: this.options.redirectOnClose || false
4✔
879
        });
880

881
        this.modal = modal;
2✔
882

883
        Helpers.removeEventListener('modal-loaded.delete-plugin');
2✔
884
        Helpers.addEventListener('modal-loaded.delete-plugin', (e, { instance }) => {
2✔
885
            if (instance === modal) {
5✔
886
                Plugin._removeAddPluginPlaceholder();
1✔
887
            }
888
        });
889
        modal.open({
2✔
890
            url: url,
891
            title: name,
892
            breadcrumbs: breadcrumb
893
        });
894
    },
895

896
    /**
897
     * Destroys the current plugin instance removing only the DOM listeners
898
     *
899
     * @method destroy
900
     * @param {Object}  options - destroy config options
901
     * @param {Boolean} options.mustCleanup - if true it will remove also the plugin UI components from the DOM
902
     * @returns {void}
903
     */
904
    destroy(options = {}) {
1✔
905
        const mustCleanup = options.mustCleanup || false;
2✔
906

907
        // close the plugin modal if it was open
908
        if (this.modal) {
2!
UNCOV
909
            this.modal.close();
×
910
            // unsubscribe to all the modal events
UNCOV
911
            this.modal.off();
×
912
        }
913

914
        if (mustCleanup) {
2✔
915
            this.cleanup();
1✔
916
        }
917

918
        // remove event bound to global elements like document or window
919
        $document.off(`.${this.uid}`);
2✔
920
        $window.off(`.${this.uid}`);
2✔
921
    },
922

923
    /**
924
     * Remove the plugin specific ui elements from the DOM
925
     *
926
     * @method cleanup
927
     * @returns {void}
928
     */
929
    cleanup() {
930
        // remove all the plugin UI DOM elements
931
        // notice that $.remove will remove also all the ui specific events
932
        // previously attached to them
933
        Object.keys(this.ui).forEach(el => this.ui[el].remove());
12✔
934
    },
935

936
    /**
937
     * Called after plugin is added through ajax.
938
     *
939
     * @method editPluginPostAjax
940
     * @param {Object} toolbar CMS.API.Toolbar instance (not used)
941
     * @param {Object} response response from server
942
     */
943
    editPluginPostAjax: function(toolbar, response) {
944
        this.editPlugin(Helpers.updateUrlWithPath(response.url), this.options.plugin_name, response.breadcrumb);
1✔
945
    },
946

947
    /**
948
     * _setSettingsMenu sets up event handlers for settings menu.
949
     *
950
     * @method _setSettingsMenu
951
     * @private
952
     * @param {jQuery} nav
953
     */
954
    _setSettingsMenu: function _setSettingsMenu(nav) {
955
        var that = this;
153✔
956

957
        this.ui.dropdown = nav.siblings('.cms-submenu-dropdown-settings');
153✔
958
        var dropdown = this.ui.dropdown;
153✔
959

960
        nav
153✔
961
            .off(Plugin.pointerUp)
962
            .on(Plugin.pointerUp, function(e) {
UNCOV
963
                e.preventDefault();
×
UNCOV
964
                e.stopPropagation();
×
UNCOV
965
                var trigger = $(this);
×
966

UNCOV
967
                if (trigger.hasClass('cms-btn-active')) {
×
UNCOV
968
                    Plugin._hideSettingsMenu(trigger);
×
969
                } else {
UNCOV
970
                    Plugin._hideSettingsMenu();
×
UNCOV
971
                    that._showSettingsMenu(trigger);
×
972
                }
973
            })
974
            .off(Plugin.touchStart)
975
            .on(Plugin.touchStart, function(e) {
976
                // required on some touch devices so
977
                // ui touch punch is not triggering mousemove
978
                // which in turn results in pep triggering pointercancel
UNCOV
979
                e.stopPropagation();
×
980
            });
981

982
        dropdown
153✔
983
            .off(Plugin.mouseEvents)
984
            .on(Plugin.mouseEvents, function(e) {
UNCOV
985
                e.stopPropagation();
×
986
            })
987
            .off(Plugin.touchStart)
988
            .on(Plugin.touchStart, function(e) {
989
                // required for scrolling on mobile
UNCOV
990
                e.stopPropagation();
×
991
            });
992

993
        that._setupActions(nav);
153✔
994
        // prevent propagation
995
        nav
153✔
996
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '))
997
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
UNCOV
998
                e.stopPropagation();
×
999
            });
1000

1001
        nav
153✔
1002
            .siblings('.cms-quicksearch, .cms-submenu-dropdown-settings')
1003
            .off([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '))
1004
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
UNCOV
1005
                e.stopPropagation();
×
1006
            });
1007
    },
1008

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

1032
        if (!isInViewport) {
3✔
1033
            scrollable.animate(
2✔
1034
                {
1035
                    scrollTop: elPosition + offset + elHeight + scrollTop - scrollHeight
1036
                },
1037
                duration
1038
            );
1039
        }
1040
    },
1041

1042
    /**
1043
     * Opens a modal with traversable plugins list, adds a placeholder to where
1044
     * the plugin will be added.
1045
     *
1046
     * @method _setAddPluginModal
1047
     * @private
1048
     * @param {jQuery} nav modal trigger element
1049
     * @returns {Boolean|void}
1050
     */
1051
    _setAddPluginModal: function _setAddPluginModal(nav) {
1052
        if (nav.hasClass('cms-btn-disabled')) {
153✔
1053
            return false;
88✔
1054
        }
1055
        var that = this;
65✔
1056
        var modal;
1057
        var possibleChildClasses;
1058
        var isTouching;
1059
        var plugins;
1060

1061
        var initModal = once(function initModal() {
65✔
1062
            var placeholder = $(
×
1063
                '<div class="cms-add-plugin-placeholder">' + CMS.config.lang.addPluginPlaceholder + '</div>'
1064
            );
1065
            var dragItem = nav.closest('.cms-dragitem');
×
1066
            var isPlaceholder = !dragItem.length;
×
1067
            var childrenList;
1068

1069
            modal = new Modal({
×
1070
                minWidth: 400,
1071
                minHeight: 400
1072
            });
1073

UNCOV
1074
            if (isPlaceholder) {
×
UNCOV
1075
                childrenList = nav.closest('.cms-dragarea').find('> .cms-draggables');
×
1076
            } else {
1077
                childrenList = nav.closest('.cms-draggable').find('> .cms-draggables');
×
1078
            }
1079

1080
            Helpers.addEventListener('modal-loaded', (e, { instance }) => {
×
UNCOV
1081
                if (instance !== modal) {
×
1082
                    return;
×
1083
                }
1084

1085
                that._setupKeyboardTraversing();
×
UNCOV
1086
                if (childrenList.hasClass('cms-hidden') && !isPlaceholder) {
×
1087
                    that._toggleCollapsable(dragItem);
×
1088
                }
UNCOV
1089
                Plugin._removeAddPluginPlaceholder();
×
1090
                placeholder.appendTo(childrenList);
×
UNCOV
1091
                that._scrollToElement(placeholder);
×
1092
            });
1093

UNCOV
1094
            Helpers.addEventListener('modal-closed', (e, { instance }) => {
×
UNCOV
1095
                if (instance !== modal) {
×
UNCOV
1096
                    return;
×
1097
                }
UNCOV
1098
                Plugin._removeAddPluginPlaceholder();
×
1099
            });
1100

1101
            Helpers.addEventListener('modal-shown', (e, { instance }) => {
×
UNCOV
1102
                if (modal !== instance) {
×
UNCOV
1103
                    return;
×
1104
                }
1105
                var dropdown = $('.cms-modal-markup .cms-plugin-picker');
×
1106

1107
                if (!isTouching) {
×
1108
                    // only focus the field if using mouse
1109
                    // otherwise keyboard pops up
UNCOV
1110
                    dropdown.find('input').trigger('focus');
×
1111
                }
UNCOV
1112
                isTouching = false;
×
1113
            });
1114

UNCOV
1115
            plugins = nav.siblings('.cms-plugin-picker');
×
1116

UNCOV
1117
            that._setupQuickSearch(plugins);
×
1118
        });
1119

1120
        nav
65✔
1121
            .on(Plugin.touchStart, function(e) {
UNCOV
1122
                isTouching = true;
×
1123
                // required on some touch devices so
1124
                // ui touch punch is not triggering mousemove
1125
                // which in turn results in pep triggering pointercancel
UNCOV
1126
                e.stopPropagation();
×
1127
            })
1128
            .on(Plugin.pointerUp, function(e) {
UNCOV
1129
                e.preventDefault();
×
1130
                e.stopPropagation();
×
1131

UNCOV
1132
                Plugin._hideSettingsMenu();
×
1133

UNCOV
1134
                possibleChildClasses = that._getPossibleChildClasses();
×
UNCOV
1135
                var selectionNeeded = possibleChildClasses.filter(':not(.cms-submenu-item-title)').length !== 1;
×
1136

UNCOV
1137
                if (selectionNeeded) {
×
UNCOV
1138
                    initModal();
×
1139

1140
                    // since we don't know exact plugin parent (because dragndrop)
1141
                    // we need to know the parent id by the time we open "add plugin" dialog
UNCOV
1142
                    var pluginsCopy = that._updateWithMostUsedPlugins(
×
1143
                        plugins
1144
                            .clone(true, true)
1145
                            .data('parentId', that._getId(nav.closest('.cms-draggable')))
1146
                            .append(possibleChildClasses)
1147
                    );
1148

UNCOV
1149
                    modal.open({
×
1150
                        title: that.options.addPluginHelpTitle,
1151
                        html: pluginsCopy,
1152
                        width: 530,
1153
                        height: 400
1154
                    });
1155
                } else {
1156
                    // only one plugin available, no need to show the modal
1157
                    // instead directly add the single plugin
UNCOV
1158
                    const el = possibleChildClasses.find('a');  // only one result
×
1159
                    const pluginType = el.attr('href').replace('#', '');
×
1160
                    const showAddForm = el.data('addForm');
×
UNCOV
1161
                    const parentId = that._getId(nav.closest('.cms-draggable'));
×
1162

1163
                    that.addPlugin(pluginType, el.text(), parentId, showAddForm);
×
1164
                }
1165
            });
1166

1167
        // prevent propagation
1168
        nav.on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
65✔
1169
            e.stopPropagation();
×
1170
        });
1171

1172
        nav
65✔
1173
            .siblings('.cms-quicksearch, .cms-submenu-dropdown')
1174
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
UNCOV
1175
                e.stopPropagation();
×
1176
            });
1177
    },
1178

1179
    _updateWithMostUsedPlugins: function _updateWithMostUsedPlugins(plugins) {
UNCOV
1180
        const items = plugins.find('.cms-submenu-item');
×
1181
        // eslint-disable-next-line no-unused-vars
UNCOV
1182
        const mostUsedPlugins = toPairs(pluginUsageMap).sort(([x, a], [y, b]) => a - b).reverse();
×
UNCOV
1183
        const MAX_MOST_USED_PLUGINS = 5;
×
UNCOV
1184
        let count = 0;
×
1185

UNCOV
1186
        if (items.filter(':not(.cms-submenu-item-title)').length <= MAX_MOST_USED_PLUGINS) {
×
UNCOV
1187
            return plugins;
×
1188
        }
1189

UNCOV
1190
        let ref = plugins.find('.cms-quicksearch');
×
1191

UNCOV
1192
        mostUsedPlugins.forEach(([name]) => {
×
UNCOV
1193
            if (count === MAX_MOST_USED_PLUGINS) {
×
UNCOV
1194
                return;
×
1195
            }
UNCOV
1196
            const item = items.find(`[href=${name}]`);
×
1197

UNCOV
1198
            if (item.length) {
×
UNCOV
1199
                const clone = item.closest('.cms-submenu-item').clone(true, true);
×
1200

UNCOV
1201
                ref.after(clone);
×
UNCOV
1202
                ref = clone;
×
UNCOV
1203
                count += 1;
×
1204
            }
1205
        });
1206

UNCOV
1207
        if (count) {
×
UNCOV
1208
            plugins.find('.cms-quicksearch').after(
×
1209
                $(`<div class="cms-submenu-item cms-submenu-item-title" data-cms-most-used>
1210
                    <span>${CMS.config.lang.mostUsed}</span>
1211
                </div>`)
1212
            );
1213
        }
1214

UNCOV
1215
        return plugins;
×
1216
    },
1217

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

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

1253
        if (childRestrictions && childRestrictions.length) {
33✔
1254
            resultElements = resultElements.filter(function() {
29✔
1255
                var item = $(this);
4,727✔
1256

1257
                return (
4,727✔
1258
                    item.hasClass('cms-submenu-item-title') ||
9,106✔
1259
                    childRestrictions.indexOf(item.find('a').attr('href')) !== -1
1260
                );
1261
            });
1262

1263
            resultElements = resultElements.filter(function(index) {
29✔
1264
                var item = $(this);
411✔
1265

1266
                return (
411✔
1267
                    !item.hasClass('cms-submenu-item-title') ||
1,182✔
1268
                    (item.hasClass('cms-submenu-item-title') &&
1269
                        (!resultElements.eq(index + 1).hasClass('cms-submenu-item-title') &&
1270
                            resultElements.eq(index + 1).length))
1271
                );
1272
            });
1273
        }
1274

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

1277
        return resultElements;
33✔
1278
    },
1279

1280
    /**
1281
     * Sets up event handlers for quicksearching in the plugin picker.
1282
     *
1283
     * @method _setupQuickSearch
1284
     * @private
1285
     * @param {jQuery} plugins plugins picker element
1286
     */
1287
    _setupQuickSearch: function _setupQuickSearch(plugins) {
UNCOV
1288
        var that = this;
×
UNCOV
1289
        var FILTER_DEBOUNCE_TIMER = 100;
×
UNCOV
1290
        var FILTER_PICK_DEBOUNCE_TIMER = 110;
×
1291

UNCOV
1292
        var handler = debounce(function() {
×
UNCOV
1293
            var input = $(this);
×
1294
            // have to always find the pluginsPicker in the handler
1295
            // because of how we move things into/out of the modal
UNCOV
1296
            var pluginsPicker = input.closest('.cms-plugin-picker');
×
1297

UNCOV
1298
            that._filterPluginsList(pluginsPicker, input);
×
1299
        }, FILTER_DEBOUNCE_TIMER);
1300

UNCOV
1301
        plugins.find('> .cms-quicksearch').find('input').on(Plugin.keyUp, handler).on(
×
1302
            Plugin.keyUp,
1303
            debounce(function(e) {
1304
                var input;
1305
                var pluginsPicker;
1306

UNCOV
1307
                if (e.keyCode === KEYS.ENTER) {
×
UNCOV
1308
                    input = $(this);
×
UNCOV
1309
                    pluginsPicker = input.closest('.cms-plugin-picker');
×
UNCOV
1310
                    pluginsPicker
×
1311
                        .find('.cms-submenu-item')
1312
                        .not('.cms-submenu-item-title')
1313
                        .filter(':visible')
1314
                        .first()
1315
                        .find('> a')
1316
                        .focus()
1317
                        .trigger('click');
1318
                }
1319
            }, FILTER_PICK_DEBOUNCE_TIMER)
1320
        );
1321
    },
1322

1323
    /**
1324
     * Sets up click handlers for various plugin/placeholder items.
1325
     * Items can be anywhere in the plugin dragitem, not only in dropdown.
1326
     *
1327
     * @method _setupActions
1328
     * @private
1329
     * @param {jQuery} nav dropdown trigger with the items
1330
     */
1331
    _setupActions: function _setupActions(nav) {
1332
        var items = '.cms-submenu-edit, .cms-submenu-item a';
163✔
1333
        var parent = nav.parent();
163✔
1334

1335
        parent.find('.cms-submenu-edit').off(Plugin.touchStart).on(Plugin.touchStart, function(e) {
163✔
1336
            // required on some touch devices so
1337
            // ui touch punch is not triggering mousemove
1338
            // which in turn results in pep triggering pointercancel
1339
            e.stopPropagation();
1✔
1340
        });
1341
        parent.find(items).off(Plugin.click).on(Plugin.click, nav, e => this._delegate(e));
163✔
1342
    },
1343

1344
    /**
1345
     * Handler for the "action" items
1346
     *
1347
     * @method _delegate
1348
     * @param {$.Event} e event
1349
     * @private
1350
     */
1351
    // eslint-disable-next-line complexity
1352
    _delegate: function _delegate(e) {
1353
        e.preventDefault();
13✔
1354
        e.stopPropagation();
13✔
1355

1356
        var nav;
1357
        var that = this;
13✔
1358

1359
        if (e.data && e.data.nav) {
13!
UNCOV
1360
            nav = e.data.nav;
×
1361
        }
1362

1363
        // show loader and make sure scroll doesn't jump
1364
        showLoader();
13✔
1365

1366
        var items = '.cms-submenu-edit, .cms-submenu-item a';
13✔
1367
        var el = $(e.target).closest(items);
13✔
1368

1369
        Plugin._hideSettingsMenu(nav);
13✔
1370

1371
        // set switch for subnav entries
1372
        switch (el.attr('data-rel')) {
13!
1373
            // eslint-disable-next-line no-case-declarations
1374
            case 'add':
1375
                const pluginType = el.attr('href').replace('#', '');
2✔
1376
                const showAddForm = el.data('addForm');
2✔
1377

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

1436
    /**
1437
     * Sets up keyboard traversing of plugin picker.
1438
     *
1439
     * @method _setupKeyboardTraversing
1440
     * @private
1441
     */
1442
    _setupKeyboardTraversing: function _setupKeyboardTraversing() {
1443
        var dropdown = $('.cms-modal-markup .cms-plugin-picker');
3✔
1444
        const keyDownTraverseEvent = this._getNamepacedEvent(Plugin.keyDown, 'traverse');
3✔
1445

1446
        if (!dropdown.length) {
3✔
1447
            return;
1✔
1448
        }
1449
        // add key events
1450
        $document.off(keyDownTraverseEvent);
2✔
1451
        // istanbul ignore next: not really possible to reproduce focus state in unit tests
1452
        $document.on(keyDownTraverseEvent, function(e) {
1453
            var anchors = dropdown.find('.cms-submenu-item:visible a');
1454
            var index = anchors.index(anchors.filter(':focus'));
1455

1456
            // bind arrow down and tab keys
1457
            if (e.keyCode === KEYS.DOWN || (e.keyCode === KEYS.TAB && !e.shiftKey)) {
1458
                e.preventDefault();
1459
                if (index >= 0 && index < anchors.length - 1) {
1460
                    anchors.eq(index + 1).focus();
1461
                } else {
1462
                    anchors.eq(0).focus();
1463
                }
1464
            }
1465

1466
            // bind arrow up and shift+tab keys
1467
            if (e.keyCode === KEYS.UP || (e.keyCode === KEYS.TAB && e.shiftKey)) {
1468
                e.preventDefault();
1469
                if (anchors.is(':focus')) {
1470
                    anchors.eq(index - 1).focus();
1471
                } else {
1472
                    anchors.eq(anchors.length).focus();
1473
                }
1474
            }
1475
        });
1476
    },
1477

1478
    /**
1479
     * Opens the settings menu for a plugin.
1480
     *
1481
     * @method _showSettingsMenu
1482
     * @private
1483
     * @param {jQuery} nav trigger element
1484
     */
1485
    _showSettingsMenu: function(nav) {
UNCOV
1486
        this._checkIfPasteAllowed();
×
1487

UNCOV
1488
        var dropdown = this.ui.dropdown;
×
UNCOV
1489
        var parents = nav.parentsUntil('.cms-dragarea').last();
×
UNCOV
1490
        var MIN_SCREEN_MARGIN = 10;
×
1491

UNCOV
1492
        nav.addClass('cms-btn-active');
×
UNCOV
1493
        parents.addClass('cms-z-index-9999');
×
1494

1495
        // set visible states
UNCOV
1496
        dropdown.show();
×
1497

1498
        // calculate dropdown positioning
UNCOV
1499
        if (
×
1500
            $window.height() + $window.scrollTop() - nav.offset().top - dropdown.height() <= MIN_SCREEN_MARGIN &&
×
1501
            nav.offset().top - dropdown.height() >= 0
1502
        ) {
UNCOV
1503
            dropdown.removeClass('cms-submenu-dropdown-top').addClass('cms-submenu-dropdown-bottom');
×
1504
        } else {
UNCOV
1505
            dropdown.removeClass('cms-submenu-dropdown-bottom').addClass('cms-submenu-dropdown-top');
×
1506
        }
1507
    },
1508

1509
    /**
1510
     * Filters given plugins list by a query.
1511
     *
1512
     * @method _filterPluginsList
1513
     * @private
1514
     * @param {jQuery} list plugins picker element
1515
     * @param {jQuery} input input, which value to filter plugins with
1516
     * @returns {Boolean|void}
1517
     */
1518
    _filterPluginsList: function _filterPluginsList(list, input) {
1519
        var items = list.find('.cms-submenu-item');
5✔
1520
        var titles = list.find('.cms-submenu-item-title');
5✔
1521
        var query = input.val();
5✔
1522

1523
        // cancel if query is zero
1524
        if (query === '') {
5✔
1525
            items.add(titles).show();
1✔
1526
            return false;
1✔
1527
        }
1528

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

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

1533
        var itemsToFilter = items.toArray().map(function(el) {
4✔
1534
            var element = $(el);
72✔
1535

1536
            return {
72✔
1537
                value: element.text(),
1538
                element: element
1539
            };
1540
        });
1541

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

1544
        items.hide();
4✔
1545
        filteredItems.forEach(function(item) {
4✔
1546
            item.element.show();
3✔
1547
        });
1548

1549
        // check if a title is matching
1550
        titles.filter(':visible').each(function(index, item) {
4✔
1551
            titles.hide();
1✔
1552
            $(item).nextUntil('.cms-submenu-item-title').show();
1✔
1553
        });
1554

1555
        // always display title of a category
1556
        items.filter(':visible').each(function(index, titleItem) {
4✔
1557
            var item = $(titleItem);
16✔
1558

1559
            if (item.prev().hasClass('cms-submenu-item-title')) {
16✔
1560
                item.prev().show();
2✔
1561
            } else {
1562
                item.prevUntil('.cms-submenu-item-title').last().prev().show();
14✔
1563
            }
1564
        });
1565

1566
        mostRecentItems.hide();
4✔
1567
    },
1568

1569
    /**
1570
     * Toggles collapsable item.
1571
     *
1572
     * @method _toggleCollapsable
1573
     * @private
1574
     * @param {jQuery} el element to toggle
1575
     * @returns {Boolean|void}
1576
     */
1577
    _toggleCollapsable: function toggleCollapsable(el) {
UNCOV
1578
        var that = this;
×
UNCOV
1579
        var id = that._getId(el.parent());
×
UNCOV
1580
        var draggable = el.closest('.cms-draggable');
×
1581
        var items;
1582

1583
        var settings = CMS.settings;
×
1584

UNCOV
1585
        settings.states = settings.states || [];
×
1586

1587
        if (!draggable || !draggable.length) {
×
UNCOV
1588
            return;
×
1589
        }
1590

1591
        // collapsable function and save states
UNCOV
1592
        if (el.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1593
            settings.states.splice($.inArray(id, settings.states), 1);
×
UNCOV
1594
            el
×
1595
                .removeClass('cms-dragitem-expanded')
1596
                .parent()
1597
                .find('> .cms-collapsable-container')
1598
                .addClass('cms-hidden');
1599

UNCOV
1600
            if ($document.data('expandmode')) {
×
UNCOV
1601
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1602
                if (!items.length) {
×
UNCOV
1603
                    return false;
×
1604
                }
UNCOV
1605
                items.each(function() {
×
1606
                    var item = $(this);
×
1607

UNCOV
1608
                    if (item.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1609
                        that._toggleCollapsable(item);
×
1610
                    }
1611
                });
1612
            }
1613
        } else {
1614
            settings.states.push(id);
×
1615
            el
×
1616
                .addClass('cms-dragitem-expanded')
1617
                .parent()
1618
                .find('> .cms-collapsable-container')
1619
                .removeClass('cms-hidden');
1620

UNCOV
1621
            if ($document.data('expandmode')) {
×
1622
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1623
                if (!items.length) {
×
UNCOV
1624
                    return false;
×
1625
                }
UNCOV
1626
                items.each(function() {
×
1627
                    var item = $(this);
×
1628

1629
                    if (!item.hasClass('cms-dragitem-expanded')) {
×
1630
                        that._toggleCollapsable(item);
×
1631
                    }
1632
                });
1633
            }
1634
        }
1635

UNCOV
1636
        this._updatePlaceholderCollapseState();
×
1637

1638
        // make sure structurboard gets updated after expanding
UNCOV
1639
        $document.trigger('resize.sideframe');
×
1640

1641
        // save settings
UNCOV
1642
        Helpers.setSettings(settings);
×
1643
    },
1644

1645
    _updatePlaceholderCollapseState() {
UNCOV
1646
        if (this.options.type !== 'plugin' || !this.options.placeholder_id) {
×
UNCOV
1647
            return;
×
1648
        }
1649

UNCOV
1650
        const pluginsOfCurrentPlaceholder = CMS._plugins
×
UNCOV
1651
            .filter(([, o]) => o.placeholder_id === this.options.placeholder_id && o.type === 'plugin')
×
UNCOV
1652
            .map(([, o]) => o.plugin_id);
×
1653

UNCOV
1654
        const openedPlugins = CMS.settings.states;
×
UNCOV
1655
        const closedPlugins = difference(pluginsOfCurrentPlaceholder, openedPlugins);
×
UNCOV
1656
        const areAllRemainingPluginsLeafs = every(closedPlugins, id => {
×
UNCOV
1657
            return !find(
×
1658
                CMS._plugins,
UNCOV
1659
                ([, o]) => o.placeholder_id === this.options.placeholder_id && o.plugin_parent === id
×
1660
            );
1661
        });
UNCOV
1662
        const el = $(`.cms-dragarea-${this.options.placeholder_id} .cms-dragbar-title`);
×
1663
        var settings = CMS.settings;
×
1664

UNCOV
1665
        if (areAllRemainingPluginsLeafs) {
×
1666
            // meaning that all plugins in current placeholder are expanded
UNCOV
1667
            el.addClass('cms-dragbar-title-expanded');
×
1668

UNCOV
1669
            settings.dragbars = settings.dragbars || [];
×
UNCOV
1670
            settings.dragbars.push(this.options.placeholder_id);
×
1671
        } else {
1672
            el.removeClass('cms-dragbar-title-expanded');
×
1673

UNCOV
1674
            settings.dragbars = settings.dragbars || [];
×
1675
            settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1676
        }
1677
    },
1678

1679
    /**
1680
     * Sets up collabspable event handlers.
1681
     *
1682
     * @method _collapsables
1683
     * @private
1684
     * @returns {Boolean|void}
1685
     */
1686
    _collapsables: function() {
1687
        // one time setup
1688
        var that = this;
153✔
1689

1690
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
153✔
1691
        // cancel here if its not a draggable
1692
        if (!this.ui.draggable.length) {
153✔
1693
            return false;
38✔
1694
        }
1695

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

1698
        // check which button should be shown for collapsemenu
1699
        var els = this.ui.draggable.find('.cms-dragitem-collapsable');
115✔
1700
        var open = els.filter('.cms-dragitem-expanded');
115✔
1701

1702
        if (els.length === open.length && els.length + open.length !== 0) {
115!
UNCOV
1703
            this.ui.draggable.find('.cms-dragbar-title').addClass('cms-dragbar-title-expanded');
×
1704
        }
1705

1706
        // attach events to draggable
1707
        // debounce here required because on some devices click is not triggered,
1708
        // so we consolidate latest click and touch event to run the collapse only once
1709
        dragitem.find('> .cms-dragitem-text').on(
115✔
1710
            Plugin.touchEnd + ' ' + Plugin.click,
1711
            debounce(function() {
UNCOV
1712
                if (!dragitem.hasClass('cms-dragitem-collapsable')) {
×
UNCOV
1713
                    return;
×
1714
                }
UNCOV
1715
                that._toggleCollapsable(dragitem);
×
1716
            }, 0)
1717
        );
1718
    },
1719

1720
    /**
1721
     * Expands all the collapsables in the given placeholder.
1722
     *
1723
     * @method _expandAll
1724
     * @private
1725
     * @param {jQuery} el trigger element that is a child of a placeholder
1726
     * @returns {Boolean|void}
1727
     */
1728
    _expandAll: function(el) {
UNCOV
1729
        var that = this;
×
UNCOV
1730
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1731

1732
        // cancel if there are no items
UNCOV
1733
        if (!items.length) {
×
1734
            return false;
×
1735
        }
1736
        items.each(function() {
×
1737
            var item = $(this);
×
1738

UNCOV
1739
            if (!item.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1740
                that._toggleCollapsable(item);
×
1741
            }
1742
        });
1743

UNCOV
1744
        el.addClass('cms-dragbar-title-expanded');
×
1745

UNCOV
1746
        var settings = CMS.settings;
×
1747

UNCOV
1748
        settings.dragbars = settings.dragbars || [];
×
UNCOV
1749
        settings.dragbars.push(this.options.placeholder_id);
×
UNCOV
1750
        Helpers.setSettings(settings);
×
1751
    },
1752

1753
    /**
1754
     * Collapses all the collapsables in the given placeholder.
1755
     *
1756
     * @method _collapseAll
1757
     * @private
1758
     * @param {jQuery} el trigger element that is a child of a placeholder
1759
     */
1760
    _collapseAll: function(el) {
UNCOV
1761
        var that = this;
×
1762
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1763

UNCOV
1764
        items.each(function() {
×
UNCOV
1765
            var item = $(this);
×
1766

UNCOV
1767
            if (item.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1768
                that._toggleCollapsable(item);
×
1769
            }
1770
        });
1771

UNCOV
1772
        el.removeClass('cms-dragbar-title-expanded');
×
1773

UNCOV
1774
        var settings = CMS.settings;
×
1775

UNCOV
1776
        settings.dragbars = settings.dragbars || [];
×
UNCOV
1777
        settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
UNCOV
1778
        Helpers.setSettings(settings);
×
1779
    },
1780

1781
    /**
1782
     * Gets the id of the element, uses CMS.StructureBoard instance.
1783
     *
1784
     * @method _getId
1785
     * @private
1786
     * @param {jQuery} el element to get id from
1787
     * @returns {String}
1788
     */
1789
    _getId: function(el) {
1790
        return CMS.API.StructureBoard.getId(el);
36✔
1791
    },
1792

1793
    /**
1794
     * Gets the ids of the list of elements, uses CMS.StructureBoard instance.
1795
     *
1796
     * @method _getIds
1797
     * @private
1798
     * @param {jQuery} els elements to get id from
1799
     * @returns {String[]}
1800
     */
1801
    _getIds: function(els) {
UNCOV
1802
        return CMS.API.StructureBoard.getIds(els);
×
1803
    },
1804

1805
    /**
1806
     * Traverses the registry to find plugin parents
1807
     *
1808
     * @method _getPluginBreadcrumbs
1809
     * @returns {Object[]} array of breadcrumbs in `{ url, title }` format
1810
     * @private
1811
     */
1812
    _getPluginBreadcrumbs: function _getPluginBreadcrumbs() {
1813
        var breadcrumbs = [];
6✔
1814

1815
        breadcrumbs.unshift({
6✔
1816
            title: this.options.plugin_name,
1817
            url: this.options.urls.edit_plugin
1818
        });
1819

1820
        var findParentPlugin = function(id) {
6✔
1821
            return $.grep(CMS._plugins || [], function(pluginOptions) {
6✔
1822
                return pluginOptions[0] === 'cms-plugin-' + id;
10✔
1823
            })[0];
1824
        };
1825

1826
        var id = this.options.plugin_parent;
6✔
1827
        var data;
1828

1829
        while (id && id !== 'None') {
6✔
1830
            data = findParentPlugin(id);
6✔
1831

1832
            if (!data) {
6✔
1833
                break;
1✔
1834
            }
1835

1836
            breadcrumbs.unshift({
5✔
1837
                title: data[1].plugin_name,
1838
                url: data[1].urls.edit_plugin
1839
            });
1840
            id = data[1].plugin_parent;
5✔
1841
        }
1842

1843
        return breadcrumbs;
6✔
1844
    }
1845
});
1846

1847
Plugin.click = 'click.cms.plugin';
1✔
1848
Plugin.pointerUp = 'pointerup.cms.plugin';
1✔
1849
Plugin.pointerDown = 'pointerdown.cms.plugin';
1✔
1850
Plugin.pointerOverAndOut = 'pointerover.cms.plugin pointerout.cms.plugin';
1✔
1851
Plugin.doubleClick = 'dblclick.cms.plugin';
1✔
1852
Plugin.keyUp = 'keyup.cms.plugin';
1✔
1853
Plugin.keyDown = 'keydown.cms.plugin';
1✔
1854
Plugin.mouseEvents = 'mousedown.cms.plugin mousemove.cms.plugin mouseup.cms.plugin';
1✔
1855
Plugin.touchStart = 'touchstart.cms.plugin';
1✔
1856
Plugin.touchEnd = 'touchend.cms.plugin';
1✔
1857

1858
/**
1859
 * Updates plugin data in CMS._plugins / CMS._instances or creates new
1860
 * plugin instances if they didn't exist
1861
 *
1862
 * @method _updateRegistry
1863
 * @private
1864
 * @static
1865
 * @param {Object[]} plugins plugins data
1866
 */
1867
Plugin._updateRegistry = function _updateRegistry(plugins) {
1✔
UNCOV
1868
    plugins.forEach(pluginData => {
×
1869
        const pluginContainer = `cms-plugin-${pluginData.plugin_id}`;
×
UNCOV
1870
        const pluginIndex = findIndex(CMS._plugins, ([pluginStr]) => pluginStr === pluginContainer);
×
1871

UNCOV
1872
        if (pluginIndex === -1) {
×
UNCOV
1873
            CMS._plugins.push([pluginContainer, pluginData]);
×
UNCOV
1874
            CMS._instances.push(new Plugin(pluginContainer, pluginData));
×
1875
        } else {
UNCOV
1876
            Plugin.aliasPluginDuplicatesMap[pluginData.plugin_id] = false;
×
UNCOV
1877
            CMS._plugins[pluginIndex] = [pluginContainer, pluginData];
×
UNCOV
1878
            CMS._instances[pluginIndex] = new Plugin(pluginContainer, pluginData);
×
1879
        }
1880
    });
1881
};
1882

1883
/**
1884
 * Hides the opened settings menu. By default looks for any open ones.
1885
 *
1886
 * @method _hideSettingsMenu
1887
 * @static
1888
 * @private
1889
 * @param {jQuery} [navEl] element representing the subnav trigger
1890
 */
1891
Plugin._hideSettingsMenu = function(navEl) {
1✔
1892
    var nav = navEl || $('.cms-submenu-btn.cms-btn-active');
20✔
1893

1894
    if (!nav.length) {
20!
1895
        return;
20✔
1896
    }
UNCOV
1897
    nav.removeClass('cms-btn-active');
×
1898

1899
    // set correct active state
UNCOV
1900
    nav.closest('.cms-draggable').data('active', false);
×
UNCOV
1901
    $('.cms-z-index-9999').removeClass('cms-z-index-9999');
×
1902

UNCOV
1903
    nav.siblings('.cms-submenu-dropdown').hide();
×
UNCOV
1904
    nav.siblings('.cms-quicksearch').hide();
×
1905
    // reset search
UNCOV
1906
    nav.siblings('.cms-quicksearch').find('input').val('').trigger(Plugin.keyUp).blur();
×
1907

1908
    // reset relativity
UNCOV
1909
    $('.cms-dragbar').css('position', '');
×
1910
};
1911

1912
/**
1913
 * Initialises handlers that affect all plugins and don't make sense
1914
 * in context of each own plugin instance, e.g. listening for a click on a document
1915
 * to hide plugin settings menu should only be applied once, and not every time
1916
 * CMS.Plugin is instantiated.
1917
 *
1918
 * @method _initializeGlobalHandlers
1919
 * @static
1920
 * @private
1921
 */
1922
Plugin._initializeGlobalHandlers = function _initializeGlobalHandlers() {
1✔
1923
    var timer;
1924
    var clickCounter = 0;
6✔
1925

1926
    Plugin._updateClipboard();
6✔
1927

1928
    // Structureboard initialized too late
1929
    setTimeout(function() {
6✔
1930
        var pluginData = {};
6✔
1931
        var html = '';
6✔
1932

1933
        if (clipboardDraggable.length) {
6✔
1934
            pluginData = find(
5✔
1935
                CMS._plugins,
1936
                ([desc]) => desc === `cms-plugin-${CMS.API.StructureBoard.getId(clipboardDraggable)}`
10✔
1937
            )[1];
1938
            html = clipboardDraggable.parent().html();
5✔
1939
        }
1940
        if (CMS.API && CMS.API.Clipboard) {
6!
1941
            CMS.API.Clipboard.populate(html, pluginData);
6✔
1942
        }
1943
    }, 0);
1944

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

1976
            // prevents single click from messing up the edit call
1977
            // don't go to the link if there is custom js attached to it
1978
            // or if it's clicked along with shift, ctrl, cmd
1979
            if (e.shiftKey || e.ctrlKey || e.metaKey || e.isDefaultPrevented()) {
×
1980
                return;
×
1981
            }
1982
            e.preventDefault();
×
1983
            if (++clickCounter === 1) {
×
UNCOV
1984
                timer = setTimeout(function() {
×
1985
                    var anchor = $(e.target).closest('a');
×
1986

UNCOV
1987
                    clickCounter = 0;
×
1988
                    window.open(anchor.attr('href'), anchor.attr('target') || '_self');
×
1989
                }, DOUBLECLICK_DELAY);
1990
            } else {
UNCOV
1991
                clearTimeout(timer);
×
1992
                clickCounter = 0;
×
1993
            }
1994
        });
1995

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

UNCOV
2005
        e.stopPropagation();
×
UNCOV
2006
        const pluginContainer = $(e.target).closest('.cms-plugin');
×
UNCOV
2007
        const allOptions = pluginContainer.data('cms');
×
2008

UNCOV
2009
        if (!allOptions || !allOptions.length) {
×
UNCOV
2010
            return;
×
2011
        }
2012

UNCOV
2013
        const options = allOptions[0];
×
2014

UNCOV
2015
        if (e.type === 'touchstart') {
×
UNCOV
2016
            CMS.API.Tooltip._forceTouchOnce();
×
2017
        }
UNCOV
2018
        var name = options.plugin_name;
×
UNCOV
2019
        var id = options.plugin_id;
×
UNCOV
2020
        var type = options.type;
×
2021

UNCOV
2022
        if (type === 'generic') {
×
UNCOV
2023
            return;
×
2024
        }
UNCOV
2025
        var placeholderId = CMS.API.StructureBoard.getId($(`.cms-draggable-${id}`).closest('.cms-dragarea'));
×
UNCOV
2026
        var placeholder = $('.cms-placeholder-' + placeholderId);
×
2027

UNCOV
2028
        if (placeholder.length && placeholder.data('cms')) {
×
UNCOV
2029
            name = placeholder.data('cms').name + ': ' + name;
×
2030
        }
2031

UNCOV
2032
        CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
2033
    });
2034

2035
    $document.on(Plugin.click, '.cms-dragarea-static .cms-dragbar', e => {
6✔
UNCOV
2036
        const placeholder = $(e.target).closest('.cms-dragarea');
×
2037

UNCOV
2038
        if (placeholder.hasClass('cms-dragarea-static-expanded') && e.isDefaultPrevented()) {
×
UNCOV
2039
            return;
×
2040
        }
2041

UNCOV
2042
        placeholder.toggleClass('cms-dragarea-static-expanded');
×
2043
    });
2044

2045
    $window.on('blur.cms', () => {
6✔
2046
        $document.data('expandmode', false);
6✔
2047
    });
2048
};
2049

2050
/**
2051
 * @method _isContainingMultiplePlugins
2052
 * @param {jQuery} node to check
2053
 * @static
2054
 * @private
2055
 * @returns {Boolean}
2056
 */
2057
Plugin._isContainingMultiplePlugins = function _isContainingMultiplePlugins(node) {
1✔
2058
    var currentData = node.data('cms');
130✔
2059

2060
    // istanbul ignore if
2061
    if (!currentData) {
130✔
2062
        throw new Error('Provided node is not a cms plugin.');
2063
    }
2064

2065
    var pluginIds = currentData.map(function(pluginData) {
130✔
2066
        return pluginData.plugin_id;
131✔
2067
    });
2068

2069
    if (pluginIds.length > 1) {
130✔
2070
        // another plugin already lives on the same node
2071
        // this only works because the plugins are rendered from
2072
        // the bottom to the top (leaf to root)
2073
        // meaning the deepest plugin is always first
2074
        return true;
1✔
2075
    }
2076

2077
    return false;
129✔
2078
};
2079

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

UNCOV
2100
    el.addClass('cms-draggable-success').append(tpl);
×
2101
    // start animation
2102
    if (successTimeout) {
×
UNCOV
2103
        setTimeout(() => {
×
UNCOV
2104
            tpl.fadeOut(successTimeout, function() {
×
UNCOV
2105
                $(this).remove();
×
2106
                el.removeClass('cms-draggable-success');
×
2107
            });
2108
        }, delay);
2109
    }
2110
    // make sure structurboard gets updated after success
UNCOV
2111
    $(Helpers._getWindow()).trigger('resize.sideframe');
×
2112
};
2113

2114
/**
2115
 * Highlights plugin in content mode
2116
 *
2117
 * @method _highlightPluginContent
2118
 * @private
2119
 * @static
2120
 * @param {String|Number} pluginId
2121
 */
2122
Plugin._highlightPluginContent = function _highlightPluginContent(
1✔
2123
    pluginId,
2124
    // eslint-disable-next-line no-magic-numbers
2125
    { successTimeout = 200, seeThrough = false, delay = 1500, prominent = false } = {}
5✔
2126
) {
2127
    var coordinates = {};
1✔
2128
    var positions = [];
1✔
2129
    var OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO = 0.2;
1✔
2130

2131
    $('.cms-plugin-' + pluginId).each(function() {
1✔
2132
        var el = $(this);
1✔
2133
        var offset = el.offset();
1✔
2134
        var ml = parseInt(el.css('margin-left'), 10);
1✔
2135
        var mr = parseInt(el.css('margin-right'), 10);
1✔
2136
        var mt = parseInt(el.css('margin-top'), 10);
1✔
2137
        var mb = parseInt(el.css('margin-bottom'), 10);
1✔
2138
        var width = el.outerWidth();
1✔
2139
        var height = el.outerHeight();
1✔
2140

2141
        if (width === 0 && height === 0) {
1!
UNCOV
2142
            return;
×
2143
        }
2144

2145
        if (isNaN(ml)) {
1!
UNCOV
2146
            ml = 0;
×
2147
        }
2148
        if (isNaN(mr)) {
1!
UNCOV
2149
            mr = 0;
×
2150
        }
2151
        if (isNaN(mt)) {
1!
UNCOV
2152
            mt = 0;
×
2153
        }
2154
        if (isNaN(mb)) {
1!
UNCOV
2155
            mb = 0;
×
2156
        }
2157

2158
        positions.push({
1✔
2159
            x1: offset.left - ml,
2160
            x2: offset.left + width + mr,
2161
            y1: offset.top - mt,
2162
            y2: offset.top + height + mb
2163
        });
2164
    });
2165

2166
    if (positions.length === 0) {
1!
UNCOV
2167
        return;
×
2168
    }
2169

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

2175
    coordinates.left = Math.min(...positions.map(pos => pos.x1));
1✔
2176
    coordinates.top = Math.min(...positions.map(pos => pos.y1)) - htmlMargin;
1✔
2177
    coordinates.width = Math.max(...positions.map(pos => pos.x2)) - coordinates.left;
1✔
2178
    coordinates.height = Math.max(...positions.map(pos => pos.y2)) - coordinates.top - htmlMargin;
1✔
2179

2180
    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);
1✔
2181

2182
    $(
1✔
2183
        `
2184
        <div class="
2185
            cms-plugin-overlay
2186
            cms-dragitem-success
2187
            cms-plugin-overlay-${pluginId}
2188
            ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}
1!
2189
            ${prominent ? 'cms-plugin-overlay-prominent' : ''}
1!
2190
        "
2191
            data-success-timeout="${successTimeout}"
2192
        >
2193
        </div>
2194
    `
2195
    )
2196
        .css(coordinates)
2197
        .css({
2198
            zIndex: 9999
2199
        })
2200
        .appendTo($('body'));
2201

2202
    if (successTimeout) {
1!
2203
        setTimeout(() => {
1✔
2204
            $(`.cms-plugin-overlay-${pluginId}`).fadeOut(successTimeout, function() {
1✔
2205
                $(this).remove();
1✔
2206
            });
2207
        }, delay);
2208
    }
2209
};
2210

2211
Plugin._clickToHighlightHandler = function _clickToHighlightHandler(e) {
1✔
UNCOV
2212
    if (CMS.settings.mode !== 'structure') {
×
UNCOV
2213
        return;
×
2214
    }
UNCOV
2215
    e.preventDefault();
×
UNCOV
2216
    e.stopPropagation();
×
2217
    // FIXME refactor into an object
UNCOV
2218
    CMS.API.StructureBoard._showAndHighlightPlugin(200, true); // eslint-disable-line no-magic-numbers
×
2219
};
2220

2221
Plugin._removeHighlightPluginContent = function(pluginId) {
1✔
UNCOV
2222
    $(`.cms-plugin-overlay-${pluginId}[data-success-timeout=0]`).remove();
×
2223
};
2224

2225
Plugin.aliasPluginDuplicatesMap = {};
1✔
2226
Plugin.staticPlaceholderDuplicatesMap = {};
1✔
2227

2228
// istanbul ignore next
2229
Plugin._initializeTree = function _initializeTree() {
2230
    const plugins = {};
2231

2232
    document.body.querySelectorAll(
2233
        'script[data-cms-plugin], ' +
2234
        'script[data-cms-placeholder], ' +
2235
        'script[data-cms-general]'
2236
    ).forEach(script => {
2237
        plugins[script.id] = JSON.parse(script.textContent || '{}');
2238
    });
2239

2240
    CMS._plugins = Object.entries(plugins);
2241
    CMS._instances = CMS._plugins.map(function(args) {
2242
        return new CMS.Plugin(args[0], args[1]);
2243
    });
2244

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

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

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

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

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

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

2268
Plugin._refreshPlugins = function refreshPlugins() {
1✔
2269
    Plugin.aliasPluginDuplicatesMap = {};
4✔
2270
    Plugin.staticPlaceholderDuplicatesMap = {};
4✔
2271

2272
    // Re-read front-end editable fields ("general" plugins) from DOM
2273
    document.body.querySelectorAll('script[data-cms-general]').forEach(script => {
4✔
UNCOV
2274
        CMS._plugins.push([script.id, JSON.parse(script.textContent)]);
×
2275
    });
2276
    // Remove duplicates
2277
    CMS._plugins = uniqWith(CMS._plugins, isEqual);
4✔
2278

2279
    CMS._instances.forEach(instance => {
4✔
2280
        if (instance.options.type === 'placeholder') {
5✔
2281
            instance._setupUI(`cms-placeholder-${instance.options.placeholder_id}`);
2✔
2282
            instance._ensureData();
2✔
2283
            instance.ui.container.data('cms', instance.options);
2✔
2284
            instance._setPlaceholder();
2✔
2285
        }
2286
    });
2287

2288
    CMS._instances.forEach(instance => {
4✔
2289
        if (instance.options.type === 'plugin') {
5✔
2290
            instance._setupUI(`cms-plugin-${instance.options.plugin_id}`);
2✔
2291
            instance._ensureData();
2✔
2292
            instance.ui.container.data('cms').push(instance.options);
2✔
2293
            instance._setPluginContentEvents();
2✔
2294
        }
2295
    });
2296

2297
    CMS._plugins.forEach(([type, opts]) => {
4✔
2298
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2299
            const instance = find(
8✔
2300
                CMS._instances,
2301
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
13✔
2302
            );
2303

2304
            if (instance) {
8✔
2305
                // update
2306
                instance._setupUI(type);
1✔
2307
                instance._ensureData();
1✔
2308
                instance.ui.container.data('cms').push(instance.options);
1✔
2309
                instance._setGeneric();
1✔
2310
            } else {
2311
                // create
2312
                CMS._instances.push(new Plugin(type, opts));
7✔
2313
            }
2314
        }
2315
    });
2316
};
2317

2318
Plugin._getPluginById = function(id) {
1✔
2319
    return find(CMS._instances, ({ options }) => options.type === 'plugin' && Number(options.plugin_id) === Number(id));
20!
2320
};
2321

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

2327
    plugins.forEach((element, index) => {
10✔
2328
        const pluginId = CMS.API.StructureBoard.getId($(element));
20✔
2329
        const instance = Plugin._getPluginById(pluginId);
20✔
2330

2331
        if (!instance) {
20!
2332
            return;
20✔
2333
        }
2334

UNCOV
2335
        instance.options.position = index + 1;
×
2336
    });
2337
};
2338

2339
Plugin._recalculatePluginPositions = function(action, data) {
1✔
UNCOV
2340
    if (action === 'MOVE') {
×
2341
        // le sigh - recalculate all placeholders cause we don't know from where the
2342
        // plugin was moved from
UNCOV
2343
        filter(CMS._instances, ({ options }) => options.type === 'placeholder')
×
UNCOV
2344
            .map(({ options }) => options.placeholder_id)
×
UNCOV
2345
            .forEach(placeholder_id => Plugin._updatePluginPositions(placeholder_id));
×
UNCOV
2346
    } else if (data.placeholder_id) {
×
UNCOV
2347
        Plugin._updatePluginPositions(data.placeholder_id);
×
2348
    }
2349
};
2350

2351
// shorthand for jQuery(document).ready();
2352
$(Plugin._initializeGlobalHandlers);
1✔
2353

2354
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

© 2026 Coveralls, Inc