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

divio / django-cms / #29434

17 Feb 2025 11:11PM UTC coverage: 74.846%. Remained the same
#29434

push

travis-ci

web-flow
Merge 30e70c42e into fa3618e01

1060 of 1620 branches covered (65.43%)

154 of 220 new or added lines in 3 files covered. (70.0%)

274 existing lines in 2 files now uncovered.

2547 of 3403 relevant lines covered (74.85%)

26.24 hits per line

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

59.23
/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
        const wrapper = $(`.${container}`);
179✔
121
        let 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
            const contentWrappers = wrapper.toArray().reduce((wrappers, elem) => {
136✔
134
                if (elem.classList.contains('cms-plugin-start') || wrappers.length === 0) {
274✔
135
                    // start new wrapper
136
                    wrappers.push([elem]);
137✔
137
                } else {
138
                    // belongs to previous wrapper
139
                    wrappers.at(-1).push(elem);
137✔
140
                }
141

142
                return wrappers;
274✔
143
            }, []);
144

145
            if (contentWrappers[0][0].tagName === 'TEMPLATE') {
136!
146
                // then - if the content is bracketed by two template tages - we map that structure into an array of
147
                // jquery collections from which we filter out empty ones
148
                contents = contentWrappers
136✔
149
                    .map(items => {
150
                        const templateStart = $(items[0]);
137✔
151
                        const className = templateStart.attr('class').replace('cms-plugin-start', '');
137✔
152
                        const position = templateStart.attr('data-cms-position');
137✔
153
                        let itemContents = $(nextUntil(templateStart[0], container));
137✔
154

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

160
                                element.wrap('<cms-plugin class="cms-plugin-text-node"></cms-plugin>');
10✔
161
                                itemContents[index] = element.parent()[0];
10✔
162
                            }
163
                        });
164

165
                        // otherwise we don't really need text nodes or comment nodes or empty text nodes
166
                        itemContents = itemContents.filter(function() {
137✔
167
                            return this.nodeType !== Node.TEXT_NODE && this.nodeType !== Node.COMMENT_NODE;
158✔
168
                        });
169

170
                        itemContents.addClass(`cms-plugin ${className}`);
137✔
171
                        itemContents.first().addClass('cms-plugin-start').attr('data-cms-position', position);
137✔
172
                        itemContents.last().addClass('cms-plugin-end').attr('data-cms-position', position);
137✔
173
                        return itemContents;
137✔
174
                    })
175
                    .filter(v => v.length);
137✔
176

177
                wrapper.filter('template').remove();
136✔
178
                if (contents.length) {
136!
179
                    // and then reduce it to one big collection
180
                    contents = contents.reduce((collection, items) => collection.add(items), $());
137✔
181
                }
182
            } else {
NEW
183
                contents = wrapper;
×
184
            }
185
        } else {
186
            contents = wrapper;
43✔
187
        }
188

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

194
        this.ui = this.ui || {};
179✔
195
        this.ui.container = contents;
179✔
196
    },
197

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

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

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

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

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

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

235
        this._checkIfPasteAllowed();
23✔
236
    },
237

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

253
    _setPluginStructureEvents: function _setPluginStructureEvents() {
254
        var that = this;
130✔
255

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

262
        this.ui.draggable.data('cms', this.options);
130✔
263

264
        this.ui.dragitem.on(Plugin.doubleClick, this._dblClickToEditHandler.bind(this));
130✔
265

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

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

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

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

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

286
            var data = dragitem.data('cms');
5✔
287

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

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

296
            that.movePlugin(data);
5✔
297
        });
298

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

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

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

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

UNCOV
341
        e.preventDefault();
×
342
        e.stopPropagation();
×
343

UNCOV
344
        if (!disabled.length) {
×
UNCOV
345
            that.editPlugin(
×
346
                Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
347
                that.options.plugin_name,
348
                that._getPluginBreadcrumbs()
349
            );
350
        }
351
    },
352

353
    _setPluginContentEvents: function _setPluginContentEvents() {
354
        const pluginDoubleClickEvent = this._getNamepacedEvent(Plugin.doubleClick);
130✔
355

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

383
        if (!Plugin._isContainingMultiplePlugins(this.ui.container)) {
130✔
384
            $document
129✔
385
                .off(pluginDoubleClickEvent, `.cms-plugin-${this.options.plugin_id}`)
386
                .on(
387
                    pluginDoubleClickEvent,
388
                    `.cms-plugin-${this.options.plugin_id}`,
389
                    this._dblClickToEditHandler.bind(this)
390
                );
391
        }
392
    },
393

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

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

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

UNCOV
421
                CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
422
            });
423
    },
424

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

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

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

454
        var bounds = this.options.plugin_restriction;
20✔
455

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

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

478
        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
5✔
479
        pasteItem.removeClass('cms-submenu-item-disabled');
5✔
480

481
        return true;
5✔
482
    },
483

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

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

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

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

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

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

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

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

UNCOV
557
        if (maybeChildren.length) {
×
558
            const lastChild = maybeChildren.last();
×
559

560
            const lastChildInstance = Plugin._getPluginById(this._getId(lastChild));
×
561

UNCOV
562
            return lastChildInstance.options.position + 1;
×
563
        }
564

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

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

584
        this.modal = modal;
3✔
585

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

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

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

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

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

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

672
        $.ajax(request);
8✔
673
    },
674

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

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

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

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

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

738
        const clipboardDraggableClone = clipboardDraggable.clone(true, true);
5✔
739

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

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

767
        // set correct options
768
        const options = opts || this.options;
11✔
769

770
        const dragitem = $(`.cms-draggable-${options.plugin_id}:last`);
11✔
771

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

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

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

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

796
            Plugin._updatePluginPositions(placeholder_id);
×
UNCOV
797
            Plugin._updatePluginPositions(options.placeholder_id);
×
798
        }
799

800
        const position = this.options.position;
10✔
801

802
        data.target_position = position;
10✔
803

804
        showLoader();
10✔
805

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

816
                // enable actions again
817
                CMS.API.locked = false;
4✔
818
                hideLoader();
4✔
819
            },
820
            error: jqXHR => {
821
                CMS.API.locked = false;
4✔
822
                const msg = CMS.config.lang.error;
4✔
823

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

835
    /**
836
     * Updates the structure board after failed change
837
     *
838
     * @method _refreshStructureBoard
839
     * @private
840
     */
841

842
    _refreshStructureBoard: function _refreshToolbar() {
843
        CMS.API.StructureBoard._loadedStructure = false;
7✔
844
        CMS.API.StructureBoard._loadStructure();
7✔
845
    },
846

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

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

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

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

892
        this.modal = modal;
2✔
893

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

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

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

925
        if (mustCleanup) {
2✔
926
            this.cleanup();
1✔
927
        }
928

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
1080
            modal = new Modal({
×
1081
                minWidth: 400,
1082
                minHeight: 400
1083
            });
1084

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

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

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

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

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

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

1126
            plugins = nav.siblings('.cms-plugin-picker');
×
1127

1128
            that._setupQuickSearch(plugins);
×
1129
        });
1130

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

UNCOV
1143
                Plugin._hideSettingsMenu();
×
1144

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

UNCOV
1148
                if (selectionNeeded) {
×
1149
                    initModal();
×
1150

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

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

UNCOV
1174
                    that.addPlugin(pluginType, el.text(), parentId, showAddForm);
×
1175
                }
1176
            });
1177

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

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

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

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

UNCOV
1201
        let ref = plugins.find('.cms-quicksearch');
×
1202

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

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

UNCOV
1212
                ref.after(clone);
×
1213
                ref = clone;
×
UNCOV
1214
                count += 1;
×
1215
            }
1216
        });
1217

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

1226
        return plugins;
×
1227
    },
1228

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

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

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

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

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

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

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

1288
        return resultElements;
33✔
1289
    },
1290

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

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

UNCOV
1309
            that._filterPluginsList(pluginsPicker, input);
×
1310
        }, FILTER_DEBOUNCE_TIMER);
1311

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

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

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

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

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

1367
        var nav;
1368
        var that = this;
13✔
1369

1370
        if (e.data && e.data.nav) {
13!
UNCOV
1371
            nav = e.data.nav;
×
1372
        }
1373

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

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

1380
        Plugin._hideSettingsMenu(nav);
13✔
1381

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

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

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

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

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

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

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

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

UNCOV
1503
        nav.addClass('cms-btn-active');
×
UNCOV
1504
        parents.addClass('cms-z-index-9999');
×
1505

1506
        // set visible states
UNCOV
1507
        dropdown.show();
×
1508

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

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

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

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

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

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

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

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

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

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

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

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

1577
        mostRecentItems.hide();
4✔
1578
    },
1579

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

UNCOV
1594
        var settings = CMS.settings;
×
1595

UNCOV
1596
        settings.states = settings.states || [];
×
1597

UNCOV
1598
        if (!draggable || !draggable.length) {
×
UNCOV
1599
            return;
×
1600
        }
1601

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

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

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

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

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

1647
        this._updatePlaceholderCollapseState();
×
1648

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

1652
        // save settings
1653
        Helpers.setSettings(settings);
×
1654
    },
1655

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
1755
        el.addClass('cms-dragbar-title-expanded');
×
1756

1757
        var settings = CMS.settings;
×
1758

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

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

UNCOV
1775
        items.each(function() {
×
UNCOV
1776
            var item = $(this);
×
1777

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

UNCOV
1783
        el.removeClass('cms-dragbar-title-expanded');
×
1784

1785
        var settings = CMS.settings;
×
1786

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

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

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

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

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

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

1837
        var id = this.options.plugin_parent;
6✔
1838
        var data;
1839

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

1843
            if (!data) {
6✔
1844
                break;
1✔
1845
            }
1846

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

1854
        return breadcrumbs;
6✔
1855
    }
1856
});
1857

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

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

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

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

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

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

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

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

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

1937
    Plugin._updateClipboard();
6✔
1938

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

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

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

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

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

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

UNCOV
2016
        e.stopPropagation();
×
UNCOV
2017
        const pluginContainer = $(e.target).closest('.cms-plugin');
×
UNCOV
2018
        const allOptions = pluginContainer.data('cms');
×
2019

UNCOV
2020
        if (!allOptions || !allOptions.length) {
×
UNCOV
2021
            return;
×
2022
        }
2023

UNCOV
2024
        const options = allOptions[0];
×
2025

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

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

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

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

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

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

UNCOV
2053
        placeholder.toggleClass('cms-dragarea-static-expanded');
×
2054
    });
2055

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

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

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

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

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

2088
    return false;
129✔
2089
};
2090

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

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

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

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

2152
        if (width === 0 && height === 0) {
1!
UNCOV
2153
            return;
×
2154
        }
2155

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

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

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

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

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

2191
    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);
1✔
2192

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

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

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

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

2236
Plugin.aliasPluginDuplicatesMap = {};
1✔
2237
Plugin.staticPlaceholderDuplicatesMap = {};
1✔
2238

2239
// istanbul ignore next
2240
Plugin._initializeTree = function _initializeTree() {
2241
    const plugins = {};
2242

2243
    document.body.querySelectorAll(
2244
        'script[data-cms-plugin], ' +
2245
        'script[data-cms-placeholder], ' +
2246
        'script[data-cms-general]'
2247
    ).forEach(script => {
2248
        plugins[script.id] = JSON.parse(script.textContent || '{}');
2249
    });
2250

2251
    CMS._plugins = Object.entries(plugins);
2252
    CMS._instances = CMS._plugins.map(function(args) {
2253
        return new CMS.Plugin(args[0], args[1]);
2254
    });
2255

2256
    // return the cms plugin instances just created
2257
    return CMS._instances;
2258
};
2259

2260
Plugin._updateClipboard = function _updateClipboard() {
1✔
2261
    clipboardDraggable = $('.cms-draggable-from-clipboard:first');
7✔
2262
};
2263

2264
Plugin._updateUsageCount = function _updateUsageCount(pluginType) {
1✔
2265
    var currentValue = pluginUsageMap[pluginType] || 0;
2✔
2266

2267
    pluginUsageMap[pluginType] = currentValue + 1;
2✔
2268

2269
    if (Helpers._isStorageSupported) {
2!
UNCOV
2270
        localStorage.setItem('cms-plugin-usage', JSON.stringify(pluginUsageMap));
×
2271
    }
2272
};
2273

2274
Plugin._removeAddPluginPlaceholder = function removeAddPluginPlaceholder() {
1✔
2275
    // this can't be cached since they are created and destroyed all over the place
2276
    $('.cms-add-plugin-placeholder').remove();
10✔
2277
};
2278

2279
Plugin._refreshPlugins = function refreshPlugins() {
1✔
2280
    Plugin.aliasPluginDuplicatesMap = {};
4✔
2281
    Plugin.staticPlaceholderDuplicatesMap = {};
4✔
2282

2283
    // Re-read front-end editable fields ("general" plugins) from DOM
2284
    document.body.querySelectorAll('script[data-cms-general]').forEach(script => {
4✔
UNCOV
2285
        CMS._plugins.push([script.id, JSON.parse(script.textContent)]);
×
2286
    });
2287
    // Remove duplicates
2288
    CMS._plugins = uniqWith(CMS._plugins, isEqual);
4✔
2289

2290
    CMS._instances.forEach(instance => {
4✔
2291
        if (instance.options.type === 'placeholder') {
5✔
2292
            instance._setupUI(`cms-placeholder-${instance.options.placeholder_id}`);
2✔
2293
            instance._ensureData();
2✔
2294
            instance.ui.container.data('cms', instance.options);
2✔
2295
            instance._setPlaceholder();
2✔
2296
        }
2297
    });
2298

2299
    CMS._instances.forEach(instance => {
4✔
2300
        if (instance.options.type === 'plugin') {
5✔
2301
            instance._setupUI(`cms-plugin-${instance.options.plugin_id}`);
2✔
2302
            instance._ensureData();
2✔
2303
            instance.ui.container.data('cms').push(instance.options);
2✔
2304
            instance._setPluginContentEvents();
2✔
2305
        }
2306
    });
2307

2308
    CMS._plugins.forEach(([type, opts]) => {
4✔
2309
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2310
            const instance = find(
8✔
2311
                CMS._instances,
2312
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
13✔
2313
            );
2314

2315
            if (instance) {
8✔
2316
                // update
2317
                instance._setupUI(type);
1✔
2318
                instance._ensureData();
1✔
2319
                instance.ui.container.data('cms').push(instance.options);
1✔
2320
                instance._setGeneric();
1✔
2321
            } else {
2322
                // create
2323
                CMS._instances.push(new Plugin(type, opts));
7✔
2324
            }
2325
        }
2326
    });
2327
};
2328

2329
Plugin._getPluginById = function(id) {
1✔
2330
    return find(CMS._instances, ({ options }) => options.type === 'plugin' && Number(options.plugin_id) === Number(id));
20!
2331
};
2332

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

2338
    plugins.forEach((element, index) => {
10✔
2339
        const pluginId = CMS.API.StructureBoard.getId($(element));
20✔
2340
        const instance = Plugin._getPluginById(pluginId);
20✔
2341

2342
        if (!instance) {
20!
2343
            return;
20✔
2344
        }
2345

UNCOV
2346
        instance.options.position = index + 1;
×
2347
    });
2348
};
2349

2350
Plugin._recalculatePluginPositions = function(action, data) {
1✔
UNCOV
2351
    if (action === 'MOVE') {
×
2352
        // le sigh - recalculate all placeholders cause we don't know from where the
2353
        // plugin was moved from
UNCOV
2354
        filter(CMS._instances, ({ options }) => options.type === 'placeholder')
×
UNCOV
2355
            .map(({ options }) => options.placeholder_id)
×
UNCOV
2356
            .forEach(placeholder_id => Plugin._updatePluginPositions(placeholder_id));
×
UNCOV
2357
    } else if (data.placeholder_id) {
×
2358
        Plugin._updatePluginPositions(data.placeholder_id);
×
2359
    }
2360
};
2361

2362
// shorthand for jQuery(document).ready();
2363
$(Plugin._initializeGlobalHandlers);
1✔
2364

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

© 2025 Coveralls, Inc