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

divio / django-cms / #30078

11 Nov 2025 02:05PM UTC coverage: 90.241% (-0.01%) from 90.251%
#30078

push

travis-ci

web-flow
Merge 03851acbd into 609c5e043

1266 of 1993 branches covered (63.52%)

20 of 29 new or added lines in 4 files covered. (68.97%)

271 existing lines in 4 files now uncovered.

8942 of 9909 relevant lines covered (90.24%)

11.21 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

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

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

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

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

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

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

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

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

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

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

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

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

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

122
        // have to check for cms-plugin, there can be a case when there are multiple
123
        // static placeholders or plugins rendered twice, there could be multiple wrappers on same page
124
        if (wrapper.length > 1 && container.match(/cms-plugin/)) {
179✔
125
            // Get array [[start, end], [start, end], ...]
126
            const contentWrappers = this._extractContentWrappers(wrapper);
136✔
127

128
            if (contentWrappers[0][0].tagName === 'TEMPLATE') {
136!
129
                // then - if the content is bracketed by two template tages - we map that structure into an array of
130
                // jquery collections from which we filter out empty ones
131
                contents = contentWrappers
136✔
132
                    .map(items => this._processTemplateGroup(items, container))
137✔
133
                    .filter(v => v.length);
137✔
134

135
                wrapper.filter('template').remove();
136✔
136
                if (contents.length) {
136!
137
                    // and then reduce it to one big collection
138
                    contents = contents.reduce((collection, items) => collection.add(items), $());
137✔
139
                }
140
            } else {
UNCOV
141
                contents = wrapper;
×
142
            }
143
        } else {
144
            contents = wrapper;
43✔
145
        }
146

147
        // in clipboard can be non-existent
148
        if (!contents.length) {
179✔
149
            contents = $('<div></div>');
11✔
150
        }
151

152
        this.ui = this.ui || {};
179✔
153
        this.ui.container = contents;
179✔
154
    },
155

156
    /**
157
     * Extracts the content wrappers from the given wrapper:
158
     * It is possible that multiple plugins (more often generics) are rendered
159
     * in different places. e.g. page menu in the header and in the footer
160
     * so first, we find all the template tags, then put them in a structure like this:
161
     * [[start, end], [start, end], ...]
162
     *
163
     * @method _extractContentWrappers
164
     * @private
165
     * @param {jQuery} wrapper
166
     * @returns {Array<Array<HTMLElement>>}
167
     */
168
    _extractContentWrappers: function (wrapper) {
169
        return wrapper.toArray().reduce((wrappers, elem) => {
136✔
170
            if (elem.classList.contains('cms-plugin-start') || wrappers.length === 0) {
274✔
171
                wrappers.push([elem]);
137✔
172
            } else {
173
                wrappers.at(-1).push(elem);
137✔
174
            }
175
            return wrappers;
274✔
176
        }, []);
177
    },
178

179
    /**
180
     * Processes the template group and returns a jQuery collection
181
     * of the content bracketed by ``cms-plugin-start`` and ``cms-plugin-end``.
182
     * It also wraps any top-level text nodes in ``cms-plugin`` elements.
183
     *
184
     * @method _processTemplateGroup
185
     * @private
186
     * @param {Array<HTMLElement>} items
187
     * @param {HTMLElement} container
188
     * @returns {jQuery}
189
     * @example
190
     * // Given the following HTML:
191
     * <template class="cms-plugin cms-plugin-4711 cms-plugin-start"></template>
192
     * <p>Some text</p>
193
     * <template class="cms-plugin cms-plugin-4711 cms-plugin-end"></template>
194
     *
195
     * // The following jQuery collection will be returned:
196
     * $('<p class="cms-plugin cms-plugin-4711 cms-plugin-start cms-plugin-end">Some text</p>')
197
     */
198
    _processTemplateGroup: function (items, container) {
199
        const templateStart = $(items[0]);
137✔
200
        const className = templateStart.attr('class').replace('cms-plugin-start', '');
137✔
201
        let itemContents = $(nextUntil(templateStart[0], container));
137✔
202

203
        itemContents.each((index, el) => {
137✔
204
            if (el.nodeType === Node.TEXT_NODE && !el.textContent.match(/^\s*$/)) {
158✔
205
                const element = $(el);
10✔
206

207
                element.wrap('<cms-plugin class="cms-plugin-text-node"></cms-plugin>');
10✔
208
                itemContents[index] = element.parent()[0];
10✔
209
            }
210
        });
211

212
        itemContents = itemContents.filter(function() {
137✔
213
            return this.nodeType !== Node.TEXT_NODE && this.nodeType !== Node.COMMENT_NODE;
158✔
214
        });
215

216
        itemContents.addClass(`cms-plugin ${className}`);
137✔
217
        itemContents.first().addClass('cms-plugin-start');
137✔
218
        itemContents.last().addClass('cms-plugin-end');
137✔
219

220
        return itemContents;
137✔
221
    },
222

223
    /**
224
     * Sets up behaviours and ui for placeholder.
225
     *
226
     * @method _setPlaceholder
227
     * @private
228
     */
229
    _setPlaceholder: function() {
230
        var that = this;
23✔
231

232
        this.ui.dragbar = $('.cms-dragbar-' + this.options.placeholder_id);
23✔
233
        this.ui.draggables = this.ui.dragbar.closest('.cms-dragarea').find('> .cms-draggables');
23✔
234
        this.ui.submenu = this.ui.dragbar.find('.cms-submenu-settings');
23✔
235
        var title = this.ui.dragbar.find('.cms-dragbar-title');
23✔
236
        var togglerLinks = this.ui.dragbar.find('.cms-dragbar-toggler a');
23✔
237
        var expanded = 'cms-dragbar-title-expanded';
23✔
238

239
        // register the subnav on the placeholder
240
        this._setSettingsMenu(this.ui.submenu);
23✔
241
        this._setAddPluginModal(this.ui.dragbar.find('.cms-submenu-add'));
23✔
242

243
        // istanbul ignore next
244
        CMS.settings.dragbars = CMS.settings.dragbars || []; // expanded dragbars array
245

246
        // enable expanding/collapsing globally within the placeholder
247
        togglerLinks.off(Plugin.click).on(Plugin.click, function(e) {
23✔
248
            e.preventDefault();
×
249
            if (title.hasClass(expanded)) {
×
UNCOV
250
                that._collapseAll(title);
×
251
            } else {
UNCOV
252
                that._expandAll(title);
×
253
            }
254
        });
255

256
        if ($.inArray(this.options.placeholder_id, CMS.settings.dragbars) !== -1) {
23!
UNCOV
257
            title.addClass(expanded);
×
258
        }
259

260
        this._checkIfPasteAllowed();
23✔
261
    },
262

263
    /**
264
     * Sets up behaviours and ui for plugin.
265
     *
266
     * @method _setPlugin
267
     * @private
268
     */
269
    _setPlugin: function() {
270
        if (isStructureReady()) {
130!
271
            this._setPluginStructureEvents();
130✔
272
        }
273
        if (isContentReady()) {
130!
274
            this._setPluginContentEvents();
130✔
275
        }
276
    },
277

278
    _setPluginStructureEvents: function _setPluginStructureEvents() {
279
        var that = this;
130✔
280

281
        // filling up ui object
282
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
130✔
283
        this.ui.dragitem = this.ui.draggable.find('> .cms-dragitem');
130✔
284
        this.ui.draggables = this.ui.draggable.find('> .cms-draggables');
130✔
285
        this.ui.submenu = this.ui.dragitem.find('.cms-submenu');
130✔
286

287
        this.ui.draggable.data('cms', this.options);
130✔
288

289
        this.ui.dragitem.on(Plugin.doubleClick, this._dblClickToEditHandler.bind(this));
130✔
290

291
        // adds listener for all plugin updates
292
        this.ui.draggable.off('cms-plugins-update').on('cms-plugins-update', function(e, eventData) {
130✔
293
            e.stopPropagation();
×
UNCOV
294
            that.movePlugin(null, eventData);
×
295
        });
296

297
        // adds listener for copy/paste updates
298
        this.ui.draggable.off('cms-paste-plugin-update').on('cms-paste-plugin-update', function(e, eventData) {
130✔
299
            e.stopPropagation();
5✔
300

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

303
            // find out new placeholder id
304
            var placeholder_id = that._getId(dragitem.closest('.cms-dragarea'));
5✔
305

306
            // if placeholder_id is empty, cancel
307
            if (!placeholder_id) {
5!
UNCOV
308
                return false;
×
309
            }
310

311
            var data = dragitem.data('cms');
5✔
312

313
            data.target = placeholder_id;
5✔
314
            data.parent = that._getId(dragitem.parent().closest('.cms-draggable'));
5✔
315
            data.move_a_copy = true;
5✔
316

317
            // expand the plugin we paste to
318
            CMS.settings.states.push(data.parent);
5✔
319
            Helpers.setSettings(CMS.settings);
5✔
320

321
            that.movePlugin(data);
5✔
322
        });
323

324
        setTimeout(() => {
130✔
325
            this.ui.dragitem
130✔
326
                .on('mouseenter', e => {
327
                    e.stopPropagation();
×
328
                    if (!$document.data('expandmode')) {
×
UNCOV
329
                        return;
×
330
                    }
331
                    if (this.ui.draggable.find('> .cms-dragitem > .cms-plugin-disabled').length) {
×
UNCOV
332
                        return;
×
333
                    }
334
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
UNCOV
335
                        return;
×
336
                    }
337
                    if (CMS.API.StructureBoard.dragging) {
×
UNCOV
338
                        return;
×
339
                    }
340

UNCOV
341
                    Plugin._highlightPluginContent(this.options.plugin_id, { successTimeout: 0, seeThrough: true });
×
342
                })
343
                .on('mouseleave', e => {
344
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
UNCOV
345
                        return;
×
346
                    }
UNCOV
347
                    e.stopPropagation();
×
348

UNCOV
349
                    Plugin._removeHighlightPluginContent(this.options.plugin_id);
×
350
                });
351
            // attach event to the plugin menu
352
            this._setSettingsMenu(this.ui.submenu);
130✔
353

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

357
            // clickability of "Paste" menu item
358
            this._checkIfPasteAllowed();
130✔
359
        });
360
    },
361

362
    _dblClickToEditHandler: function _dblClickToEditHandler(e) {
363
        var that = this;
×
UNCOV
364
        var disabled = $(e.currentTarget).closest('.cms-drag-disabled');
×
365

366
        e.preventDefault();
×
UNCOV
367
        e.stopPropagation();
×
368

369
        if (!disabled.length) {
×
UNCOV
370
            that.editPlugin(
×
371
                Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
372
                that.options.plugin_name,
373
                that._getPluginBreadcrumbs()
374
            );
375
        }
376
    },
377

378
    _setPluginContentEvents: function _setPluginContentEvents() {
379
        const pluginDoubleClickEvent = this._getNamepacedEvent(Plugin.doubleClick);
130✔
380

381
        this.ui.container
130✔
382
            .off('mouseover.cms.plugins')
383
            .on('mouseover.cms.plugins', e => {
384
                if (!$document.data('expandmode')) {
×
UNCOV
385
                    return;
×
386
                }
387
                if (CMS.settings.mode !== 'structure') {
×
UNCOV
388
                    return;
×
389
                }
390
                e.stopPropagation();
×
391
                $('.cms-dragitem-success').remove();
×
392
                $('.cms-draggable-success').removeClass('cms-draggable-success');
×
NEW
393
                CMS.API.StructureBoard._showAndHighlightPlugin(0, true);
×
394
            })
395
            .off('mouseout.cms.plugins')
396
            .on('mouseout.cms.plugins', e => {
397
                if (CMS.settings.mode !== 'structure') {
×
UNCOV
398
                    return;
×
399
                }
400
                e.stopPropagation();
×
401
                if (this.ui.draggable && this.ui.draggable.length) {
×
402
                    this.ui.draggable.find('.cms-dragitem-success').remove();
×
UNCOV
403
                    this.ui.draggable.removeClass('cms-draggable-success');
×
404
                }
405
                // Plugin._removeHighlightPluginContent(this.options.plugin_id);
406
            });
407

408
        if (!Plugin._isContainingMultiplePlugins(this.ui.container)) {
130✔
409
            $document
129✔
410
                .off(pluginDoubleClickEvent, `.cms-plugin-${this.options.plugin_id}`)
411
                .on(
412
                    pluginDoubleClickEvent,
413
                    `.cms-plugin-${this.options.plugin_id}`,
414
                    this._dblClickToEditHandler.bind(this)
415
                );
416
        }
417
    },
418

419
    /**
420
     * Sets up behaviours and ui for generics.
421
     * Generics do not show up in structure board.
422
     *
423
     * @method _setGeneric
424
     * @private
425
     */
426
    _setGeneric: function() {
427
        var that = this;
24✔
428

429
        // adds double click to edit
430
        this.ui.container.off(Plugin.doubleClick).on(Plugin.doubleClick, function(e) {
24✔
431
            e.preventDefault();
×
432
            e.stopPropagation();
×
UNCOV
433
            that.editPlugin(Helpers.updateUrlWithPath(that.options.urls.edit_plugin), that.options.plugin_name, []);
×
434
        });
435

436
        // adds edit tooltip
437
        this.ui.container
24✔
438
            .off(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart)
439
            .on(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart, function(e) {
440
                if (e.type !== 'touchstart') {
×
UNCOV
441
                    e.stopPropagation();
×
442
                }
443
                var name = that.options.plugin_name;
×
UNCOV
444
                var id = that.options.plugin_id;
×
445

UNCOV
446
                CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
447
            });
448
    },
449

450
    /**
451
     * Checks if paste is allowed into current plugin/placeholder based
452
     * on restrictions we have. Also determines which tooltip to show.
453
     *
454
     * WARNING: this relies on clipboard plugins always being instantiated
455
     * first, so they have data('cms') by the time this method is called.
456
     *
457
     * @method _checkIfPasteAllowed
458
     * @private
459
     * @returns {Boolean}
460
     */
461
    _checkIfPasteAllowed: function _checkIfPasteAllowed() {
462
        var pasteButton = this.ui.dropdown.find('[data-rel=paste]');
151✔
463
        var pasteItem = pasteButton.parent();
151✔
464

465
        if (!clipboardDraggable.length) {
151✔
466
            pasteItem.addClass('cms-submenu-item-disabled');
86✔
467
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
86✔
468
            pasteItem.find('.cms-submenu-item-paste-tooltip-empty').css('display', 'block');
86✔
469
            return false;
86✔
470
        }
471

472
        if (this.ui.draggable && this.ui.draggable.hasClass('cms-draggable-disabled')) {
65✔
473
            pasteItem.addClass('cms-submenu-item-disabled');
45✔
474
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
45✔
475
            pasteItem.find('.cms-submenu-item-paste-tooltip-disabled').css('display', 'block');
45✔
476
            return false;
45✔
477
        }
478

479
        var bounds = this.options.plugin_restriction;
20✔
480

481
        if (clipboardDraggable.data('cms')) {
20!
482
            var clipboardPluginData = clipboardDraggable.data('cms');
20✔
483
            var type = clipboardPluginData.plugin_type;
20✔
484
            var parent_bounds = $.grep(clipboardPluginData.plugin_parent_restriction, function(restriction) {
20✔
485
                // special case when PlaceholderPlugin has a parent restriction named "0"
486
                return restriction !== '0';
20✔
487
            });
488
            var currentPluginType = this.options.plugin_type;
20✔
489

490
            if (
20✔
491
                (bounds.length && $.inArray(type, bounds) === -1) ||
60!
492
                (parent_bounds.length && $.inArray(currentPluginType, parent_bounds) === -1)
493
            ) {
494
                pasteItem.addClass('cms-submenu-item-disabled');
15✔
495
                pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
15✔
496
                pasteItem.find('.cms-submenu-item-paste-tooltip-restricted').css('display', 'block');
15✔
497
                return false;
15✔
498
            }
499
        } else {
UNCOV
500
            return false;
×
501
        }
502

503
        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
5✔
504
        pasteItem.removeClass('cms-submenu-item-disabled');
5✔
505

506
        return true;
5✔
507
    },
508

509
    /**
510
     * Calls api to create a plugin and then proceeds to edit it.
511
     *
512
     * @method addPlugin
513
     * @param {String} type type of the plugin, e.g "Bootstrap3ColumnCMSPlugin"
514
     * @param {String} name name of the plugin, e.g. "Column"
515
     * @param {String} parent id of a parent plugin
516
     * @param {Boolean} showAddForm if false, will NOT show the add form
517
     * @param {Number} position (optional) position of the plugin
518
     */
519
    // eslint-disable-next-line max-params
520
    addPlugin: function(type, name, parent, showAddForm = true, position) {
2✔
521
        var params = {
4✔
522
            placeholder_id: this.options.placeholder_id,
523
            plugin_type: type,
524
            cms_path: path,
525
            plugin_language: CMS.config.request.language,
526
            plugin_position: position || this._getPluginAddPosition()
8✔
527
        };
528

529
        if (parent) {
4✔
530
            params.plugin_parent = parent;
2✔
531
        }
532
        var url = this.options.urls.add_plugin + '?' + $.param(params);
4✔
533

534
        const modal = new Modal({
4✔
535
            onClose: this.options.onClose || false,
7✔
536
            redirectOnClose: this.options.redirectOnClose || false
7✔
537
        });
538

539
        if (showAddForm) {
4✔
540
            modal.open({
3✔
541
                url: url,
542
                title: name
543
            });
544
        } else {
545
            // Also open the modal but without the content. Instead create a form and immediately submit it.
546
            modal.open({
1✔
547
                url: '#',
548
                title: name
549
            });
550
            if (modal.ui) {
1!
551
                // Hide the plugin type selector modal if it's open
552
                modal.ui.modal.hide();
1✔
553
            }
554
            const contents = modal.ui.frame.find('iframe').contents();
1✔
555
            const body = contents.find('body');
1✔
556

557
            body.append(`<form method="post" action="${url}" style="display: none;">
1✔
558
                <input type="hidden" name="csrfmiddlewaretoken" value="${CMS.config.csrf}"></form>`);
559
            body.find('form').submit();
1✔
560
        }
561
        this.modal = modal;
4✔
562

563
        Helpers.removeEventListener('modal-closed.add-plugin');
4✔
564
        Helpers.addEventListener('modal-closed.add-plugin', (e, { instance }) => {
4✔
565
            if (instance !== modal) {
1!
UNCOV
566
                return;
×
567
            }
568
            Plugin._removeAddPluginPlaceholder();
1✔
569
        });
570
    },
571

572
    _getPluginAddPosition: function() {
573
        if (this.options.type === 'placeholder') {
×
UNCOV
574
            return $(`.cms-dragarea-${this.options.placeholder_id} .cms-draggable`).length + 1;
×
575
        }
576

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

582
        if (maybeChildren.length) {
×
UNCOV
583
            const lastChild = maybeChildren.last();
×
584

UNCOV
585
            const lastChildInstance = Plugin._getPluginById(this._getId(lastChild));
×
586

UNCOV
587
            return lastChildInstance.options.position + 1;
×
588
        }
589

UNCOV
590
        return this.options.position + 1;
×
591
    },
592

593
    /**
594
     * Opens the modal for editing a plugin.
595
     *
596
     * @method editPlugin
597
     * @param {String} url editing url
598
     * @param {String} name Name of the plugin, e.g. "Column"
599
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
600
     *     each item is `{ title: 'string': url: 'string' }`
601
     */
602
    editPlugin: function(url, name, breadcrumb) {
603
        // trigger modal window
604
        var modal = new Modal({
3✔
605
            onClose: this.options.onClose || false,
6✔
606
            redirectOnClose: this.options.redirectOnClose || false
6✔
607
        });
608

609
        this.modal = modal;
3✔
610

611
        Helpers.removeEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin');
3✔
612
        Helpers.addEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin', (e, { instance }) => {
3✔
613
            if (instance === modal) {
1!
614
                // cannot be cached
615
                Plugin._removeAddPluginPlaceholder();
1✔
616
            }
617
        });
618
        modal.open({
3✔
619
            url: url,
620
            title: name,
621
            breadcrumbs: breadcrumb,
622
            width: 850
623
        });
624
    },
625

626
    /**
627
     * Used for copying _and_ pasting a plugin. If either of params
628
     * is present method assumes that it's "paste" and will make a call
629
     * to api to insert current plugin to specified `options.target_plugin_id`
630
     * or `options.target_placeholder_id`. Copying a plugin also first
631
     * clears the clipboard.
632
     *
633
     * @method copyPlugin
634
     * @param {Object} [opts=this.options]
635
     * @param {String} source_language
636
     * @returns {Boolean|void}
637
     */
638

639
    copyPlugin: function(opts, source_language) {
640
        // cancel request if already in progress
641
        if (CMS.API.locked) {
9✔
642
            return false;
1✔
643
        }
644
        CMS.API.locked = true;
8✔
645

646
        // set correct options (don't mutate them)
647
        var options = $.extend({}, opts || this.options);
8✔
648
        var sourceLanguage = source_language;
8✔
649
        let copyingFromLanguage = false;
8✔
650

651
        if (sourceLanguage) {
8✔
652
            copyingFromLanguage = true;
1✔
653
            options.target = options.placeholder_id;
1✔
654
            options.plugin_id = '';
1✔
655
            options.parent = '';
1✔
656
        } else {
657
            sourceLanguage = CMS.config.request.language;
7✔
658
        }
659

660
        var data = {
8✔
661
            source_placeholder_id: options.placeholder_id,
662
            source_plugin_id: options.plugin_id || '',
9✔
663
            source_language: sourceLanguage,
664
            target_plugin_id: options.parent || '',
16✔
665
            target_placeholder_id: options.target || CMS.config.clipboard.id,
15✔
666
            csrfmiddlewaretoken: CMS.config.csrf,
667
            target_language: CMS.config.request.language
668
        };
669
        var request = {
8✔
670
            type: 'POST',
671
            url: Helpers.updateUrlWithPath(options.urls.copy_plugin),
672
            data: data,
673
            success: function(response) {
674
                CMS.API.Messages.open({
2✔
675
                    message: CMS.config.lang.success
676
                });
677
                if (copyingFromLanguage) {
2!
UNCOV
678
                    CMS.API.StructureBoard.invalidateState('PASTE', $.extend({}, data, response));
×
679
                } else {
680
                    CMS.API.StructureBoard.invalidateState('COPY', response);
2✔
681
                }
682
                CMS.API.locked = false;
2✔
683
                hideLoader();
2✔
684
            },
685
            error: function(jqXHR) {
686
                CMS.API.locked = false;
3✔
687
                var msg = CMS.config.lang.error;
3✔
688

689
                // trigger error
690
                CMS.API.Messages.open({
3✔
691
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
692
                    error: true
693
                });
694
            }
695
        };
696

697
        $.ajax(request);
8✔
698
    },
699

700
    /**
701
     * Essentially clears clipboard and moves plugin to a clipboard
702
     * placholder through `movePlugin`.
703
     *
704
     * @method cutPlugin
705
     * @returns {Boolean|void}
706
     */
707
    cutPlugin: function() {
708
        // if cut is once triggered, prevent additional actions
709
        if (CMS.API.locked) {
9✔
710
            return false;
1✔
711
        }
712
        CMS.API.locked = true;
8✔
713

714
        var that = this;
8✔
715
        var data = {
8✔
716
            placeholder_id: CMS.config.clipboard.id,
717
            plugin_id: this.options.plugin_id,
718
            plugin_parent: '',
719
            target_language: CMS.config.request.language,
720
            csrfmiddlewaretoken: CMS.config.csrf
721
        };
722

723
        // move plugin
724
        $.ajax({
8✔
725
            type: 'POST',
726
            url: Helpers.updateUrlWithPath(that.options.urls.move_plugin),
727
            data: data,
728
            success: function(response) {
729
                CMS.API.locked = false;
4✔
730
                CMS.API.Messages.open({
4✔
731
                    message: CMS.config.lang.success
732
                });
733
                CMS.API.StructureBoard.invalidateState('CUT', $.extend({}, data, response));
4✔
734
                hideLoader();
4✔
735
            },
736
            error: function(jqXHR) {
737
                CMS.API.locked = false;
3✔
738
                var msg = CMS.config.lang.error;
3✔
739

740
                // trigger error
741
                CMS.API.Messages.open({
3✔
742
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
743
                    error: true
744
                });
745
                hideLoader();
3✔
746
            }
747
        });
748
    },
749

750
    /**
751
     * Method is called when you click on the paste button on the plugin.
752
     * Uses existing solution of `copyPlugin(options)`
753
     *
754
     * @method pastePlugin
755
     */
756
    pastePlugin: function() {
757
        var id = this._getId(clipboardDraggable);
5✔
758
        var eventData = {
5✔
759
            id: id
760
        };
761

762
        const clipboardDraggableClone = clipboardDraggable.clone(true, true);
5✔
763

764
        clipboardDraggableClone.appendTo(this.ui.draggables);
5✔
765
        if (this.options.plugin_id) {
5✔
766
            StructureBoard.actualizePluginCollapseStatus(this.options.plugin_id);
4✔
767
        }
768
        this.ui.draggables.trigger('cms-structure-update', [eventData]);
5✔
769
        clipboardDraggableClone.trigger('cms-paste-plugin-update', [eventData]);
5✔
770
    },
771

772
    /**
773
     * Moves plugin by querying the API and then updates some UI parts
774
     * to reflect that the page has changed.
775
     *
776
     * @method movePlugin
777
     * @param {Object} [opts=this.options]
778
     * @param {String} [opts.placeholder_id]
779
     * @param {String} [opts.plugin_id]
780
     * @param {String} [opts.plugin_parent]
781
     * @param {Boolean} [opts.move_a_copy]
782
     * @returns {Boolean|void}
783
     */
784
    movePlugin: function(opts) {
785
        // cancel request if already in progress
786
        if (CMS.API.locked) {
12✔
787
            return false;
1✔
788
        }
789
        CMS.API.locked = true;
11✔
790

791
        // set correct options
792
        const options = opts || this.options;
11✔
793

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

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

799
        // cancel here if we have no placeholder id
800
        if (placeholder_id === false) {
11✔
801
            return false;
1✔
802
        }
803
        const pluginParentElement = dragitem.parent().closest('.cms-draggable');
10✔
804
        const plugin_parent = this._getId(pluginParentElement);
10✔
805

806
        // gather the data for ajax request
807
        const data = {
10✔
808
            plugin_id: options.plugin_id,
809
            plugin_parent: plugin_parent || '',
20✔
810
            target_language: CMS.config.request.language,
811
            csrfmiddlewaretoken: CMS.config.csrf,
812
            move_a_copy: options.move_a_copy
813
        };
814

815
        if (Number(placeholder_id) === Number(options.placeholder_id)) {
10!
816
            Plugin._updatePluginPositions(options.placeholder_id);
10✔
817
        } else {
UNCOV
818
            data.placeholder_id = placeholder_id;
×
819

820
            Plugin._updatePluginPositions(placeholder_id);
×
UNCOV
821
            Plugin._updatePluginPositions(options.placeholder_id);
×
822
        }
823

824
        const position = this.options.position;
10✔
825

826
        data.target_position = position;
10✔
827

828
        showLoader();
10✔
829

830
        $.ajax({
10✔
831
            type: 'POST',
832
            url: Helpers.updateUrlWithPath(options.urls.move_plugin),
833
            data: data,
834
            success: response => {
835
                CMS.API.StructureBoard.invalidateState(
4✔
836
                    data.move_a_copy ? 'PASTE' : 'MOVE',
4!
837
                    $.extend({}, data, { placeholder_id: placeholder_id }, response)
838
                );
839

840
                // enable actions again
841
                CMS.API.locked = false;
4✔
842
                hideLoader();
4✔
843
            },
844
            error: jqXHR => {
845
                CMS.API.locked = false;
4✔
846
                const msg = CMS.config.lang.error;
4✔
847

848
                // trigger error
849
                CMS.API.Messages.open({
4✔
850
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
5✔
851
                    error: true
852
                });
853
                hideLoader();
4✔
854
            }
855
        });
856
    },
857

858
    /**
859
     * Changes the settings attributes on an initialised plugin.
860
     *
861
     * @method _setSettings
862
     * @param {Object} oldSettings current settings
863
     * @param {Object} newSettings new settings to be applied
864
     * @private
865
     */
866
    _setSettings: function _setSettings(oldSettings, newSettings) {
867
        var settings = $.extend(true, {}, oldSettings, newSettings);
×
868
        var plugin = $('.cms-plugin-' + settings.plugin_id);
×
UNCOV
869
        var draggable = $('.cms-draggable-' + settings.plugin_id);
×
870

871
        // set new setting on instance and plugin data
872
        this.options = settings;
×
873
        if (plugin.length) {
×
874
            var index = plugin.data('cms').findIndex(function(pluginData) {
×
UNCOV
875
                return pluginData.plugin_id === settings.plugin_id;
×
876
            });
877

878
            plugin.each(function() {
×
UNCOV
879
                $(this).data('cms')[index] = settings;
×
880
            });
881
        }
882
        if (draggable.length) {
×
UNCOV
883
            draggable.data('cms', settings);
×
884
        }
885
    },
886

887
    /**
888
     * Opens a modal to delete a plugin.
889
     *
890
     * @method deletePlugin
891
     * @param {String} url admin url for deleting a page
892
     * @param {String} name plugin name, e.g. "Column"
893
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
894
     *     each item is `{ title: 'string': url: 'string' }`
895
     */
896
    deletePlugin: function(url, name, breadcrumb) {
897
        // trigger modal window
898
        var modal = new Modal({
2✔
899
            onClose: this.options.onClose || false,
4✔
900
            redirectOnClose: this.options.redirectOnClose || false
4✔
901
        });
902

903
        this.modal = modal;
2✔
904

905
        Helpers.removeEventListener('modal-loaded.delete-plugin');
2✔
906
        Helpers.addEventListener('modal-loaded.delete-plugin', (e, { instance }) => {
2✔
907
            if (instance === modal) {
5✔
908
                Plugin._removeAddPluginPlaceholder();
1✔
909
            }
910
        });
911
        modal.open({
2✔
912
            url: url,
913
            title: name,
914
            breadcrumbs: breadcrumb
915
        });
916
    },
917

918
    /**
919
     * Destroys the current plugin instance removing only the DOM listeners
920
     *
921
     * @method destroy
922
     * @param {Object}  options - destroy config options
923
     * @param {Boolean} options.mustCleanup - if true it will remove also the plugin UI components from the DOM
924
     * @returns {void}
925
     */
926
    destroy(options = {}) {
1✔
927
        const mustCleanup = options.mustCleanup || false;
2✔
928

929
        // close the plugin modal if it was open
930
        if (this.modal) {
2!
UNCOV
931
            this.modal.close();
×
932
            // unsubscribe to all the modal events
UNCOV
933
            this.modal.off();
×
934
        }
935

936
        if (mustCleanup) {
2✔
937
            this.cleanup();
1✔
938
        }
939

940
        // remove event bound to global elements like document or window
941
        $document.off(`.${this.uid}`);
2✔
942
        $window.off(`.${this.uid}`);
2✔
943
    },
944

945
    /**
946
     * Remove the plugin specific ui elements from the DOM
947
     *
948
     * @method cleanup
949
     * @returns {void}
950
     */
951
    cleanup() {
952
        // remove all the plugin UI DOM elements
953
        // notice that $.remove will remove also all the ui specific events
954
        // previously attached to them
955
        Object.keys(this.ui).forEach(el => this.ui[el].remove());
12✔
956
    },
957

958
    /**
959
     * Called after plugin is added through ajax.
960
     *
961
     * @method editPluginPostAjax
962
     * @param {Object} toolbar CMS.API.Toolbar instance (not used)
963
     * @param {Object} response response from server
964
     */
965
    editPluginPostAjax: function(toolbar, response) {
966
        this.editPlugin(Helpers.updateUrlWithPath(response.url), this.options.plugin_name, response.breadcrumb);
1✔
967
    },
968

969
    /**
970
     * _setSettingsMenu sets up event handlers for settings menu.
971
     *
972
     * @method _setSettingsMenu
973
     * @private
974
     * @param {jQuery} nav
975
     */
976
    _setSettingsMenu: function _setSettingsMenu(nav) {
977
        var that = this;
153✔
978

979
        this.ui.dropdown = nav.siblings('.cms-submenu-dropdown-settings');
153✔
980
        var dropdown = this.ui.dropdown;
153✔
981

982
        nav
153✔
983
            .off(Plugin.pointerUp)
984
            .on(Plugin.pointerUp, function(e) {
985
                e.preventDefault();
×
986
                e.stopPropagation();
×
UNCOV
987
                var trigger = $(this);
×
988

989
                if (trigger.hasClass('cms-btn-active')) {
×
UNCOV
990
                    Plugin._hideSettingsMenu(trigger);
×
991
                } else {
992
                    Plugin._hideSettingsMenu();
×
UNCOV
993
                    that._showSettingsMenu(trigger);
×
994
                }
995
            })
996
            .off(Plugin.touchStart)
997
            .on(Plugin.touchStart, function(e) {
998
                // required on some touch devices so
999
                // ui touch punch is not triggering mousemove
1000
                // which in turn results in pep triggering pointercancel
UNCOV
1001
                e.stopPropagation();
×
1002
            });
1003

1004
        dropdown
153✔
1005
            .off(Plugin.mouseEvents)
1006
            .on(Plugin.mouseEvents, function(e) {
UNCOV
1007
                e.stopPropagation();
×
1008
            })
1009
            .off(Plugin.touchStart)
1010
            .on(Plugin.touchStart, function(e) {
1011
                // required for scrolling on mobile
UNCOV
1012
                e.stopPropagation();
×
1013
            });
1014

1015
        that._setupActions(nav);
153✔
1016
        // prevent propagation
1017
        nav
153✔
1018
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '))
1019
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
UNCOV
1020
                e.stopPropagation();
×
1021
            });
1022

1023
        nav
153✔
1024
            .siblings('.cms-quicksearch, .cms-submenu-dropdown-settings')
1025
            .off([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '))
1026
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
UNCOV
1027
                e.stopPropagation();
×
1028
            });
1029
    },
1030

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

1054
        if (!isInViewport) {
3✔
1055
            scrollable.animate(
2✔
1056
                {
1057
                    scrollTop: elPosition + offset + elHeight + scrollTop - scrollHeight
1058
                },
1059
                duration
1060
            );
1061
        }
1062
    },
1063

1064
    /**
1065
     * Opens a modal with traversable plugins list, adds a placeholder to where
1066
     * the plugin will be added.
1067
     *
1068
     * @method _setAddPluginModal
1069
     * @private
1070
     * @param {jQuery} nav modal trigger element
1071
     * @returns {Boolean|void}
1072
     */
1073
    _setAddPluginModal: function _setAddPluginModal(nav) {
1074
        if (nav.hasClass('cms-btn-disabled')) {
153✔
1075
            return false;
88✔
1076
        }
1077
        var that = this;
65✔
1078
        var modal;
1079
        var possibleChildClasses;
1080
        var isTouching;
1081
        var plugins;
1082

1083
        var initModal = once(function initModal() {
65✔
UNCOV
1084
            var placeholder = $(
×
1085
                '<div class="cms-add-plugin-placeholder">' + CMS.config.lang.addPluginPlaceholder + '</div>'
1086
            );
1087
            var dragItem = nav.closest('.cms-dragitem');
×
UNCOV
1088
            var isPlaceholder = !dragItem.length;
×
1089
            var childrenList;
1090

UNCOV
1091
            modal = new Modal({
×
1092
                minWidth: 400,
1093
                minHeight: 400
1094
            });
1095

1096
            if (isPlaceholder) {
×
UNCOV
1097
                childrenList = nav.closest('.cms-dragarea').find('> .cms-draggables');
×
1098
            } else {
UNCOV
1099
                childrenList = nav.closest('.cms-draggable').find('> .cms-draggables');
×
1100
            }
1101

1102
            Helpers.addEventListener('modal-loaded', (e, { instance }) => {
×
1103
                if (instance !== modal) {
×
UNCOV
1104
                    return;
×
1105
                }
1106

1107
                that._setupKeyboardTraversing();
×
1108
                if (childrenList.hasClass('cms-hidden') && !isPlaceholder) {
×
UNCOV
1109
                    that._toggleCollapsable(dragItem);
×
1110
                }
1111
                Plugin._removeAddPluginPlaceholder();
×
1112
                placeholder.appendTo(childrenList);
×
UNCOV
1113
                that._scrollToElement(placeholder);
×
1114
            });
1115

1116
            Helpers.addEventListener('modal-closed', (e, { instance }) => {
×
1117
                if (instance !== modal) {
×
UNCOV
1118
                    return;
×
1119
                }
UNCOV
1120
                Plugin._removeAddPluginPlaceholder();
×
1121
            });
1122

1123
            Helpers.addEventListener('modal-shown', (e, { instance }) => {
×
1124
                if (modal !== instance) {
×
UNCOV
1125
                    return;
×
1126
                }
UNCOV
1127
                var dropdown = $('.cms-modal-markup .cms-plugin-picker');
×
1128

UNCOV
1129
                if (!isTouching) {
×
1130
                    // only focus the field if using mouse
1131
                    // otherwise keyboard pops up
UNCOV
1132
                    dropdown.find('input').trigger('focus');
×
1133
                }
UNCOV
1134
                isTouching = false;
×
1135
            });
1136

UNCOV
1137
            plugins = nav.siblings('.cms-plugin-picker');
×
1138

UNCOV
1139
            that._setupQuickSearch(plugins);
×
1140
        });
1141

1142
        nav
65✔
1143
            .on(Plugin.touchStart, function(e) {
UNCOV
1144
                isTouching = true;
×
1145
                // required on some touch devices so
1146
                // ui touch punch is not triggering mousemove
1147
                // which in turn results in pep triggering pointercancel
UNCOV
1148
                e.stopPropagation();
×
1149
            })
1150
            .on(Plugin.pointerUp, function(e) {
1151
                e.preventDefault();
×
UNCOV
1152
                e.stopPropagation();
×
1153

UNCOV
1154
                Plugin._hideSettingsMenu();
×
1155

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

1159
                if (selectionNeeded) {
×
UNCOV
1160
                    initModal();
×
1161

1162
                    // since we don't know exact plugin parent (because dragndrop)
1163
                    // we need to know the parent id by the time we open "add plugin" dialog
UNCOV
1164
                    var pluginsCopy = that._updateWithMostUsedPlugins(
×
1165
                        plugins
1166
                            .clone(true, true)
1167
                            .data('parentId', that._getId(nav.closest('.cms-draggable')))
1168
                            .append(possibleChildClasses)
1169
                    );
1170

UNCOV
1171
                    modal.open({
×
1172
                        title: that.options.addPluginHelpTitle,
1173
                        html: pluginsCopy,
1174
                        width: 530,
1175
                        height: 400
1176
                    });
1177
                } else {
1178
                    // only one plugin available, no need to show the modal
1179
                    // instead directly add the single plugin
NEW
1180
                    const el = possibleChildClasses.find('a'); // only one result
×
1181
                    const pluginType = el.attr('href').replace('#', '');
×
1182
                    const showAddForm = el.data('addForm');
×
UNCOV
1183
                    const parentId = that._getId(nav.closest('.cms-draggable'));
×
1184

UNCOV
1185
                    that.addPlugin(pluginType, el.text(), parentId, showAddForm);
×
1186
                }
1187
            });
1188

1189
        // prevent propagation
1190
        nav.on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
65✔
UNCOV
1191
            e.stopPropagation();
×
1192
        });
1193

1194
        nav
65✔
1195
            .siblings('.cms-quicksearch, .cms-submenu-dropdown')
1196
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
UNCOV
1197
                e.stopPropagation();
×
1198
            });
1199
    },
1200

1201
    _updateWithMostUsedPlugins: function _updateWithMostUsedPlugins(plugins) {
UNCOV
1202
        const items = plugins.find('.cms-submenu-item');
×
1203
        // eslint-disable-next-line no-unused-vars
1204
        const mostUsedPlugins = toPairs(pluginUsageMap).sort(([x, a], [y, b]) => a - b).reverse();
×
1205
        const MAX_MOST_USED_PLUGINS = 5;
×
UNCOV
1206
        let count = 0;
×
1207

1208
        if (items.filter(':not(.cms-submenu-item-title)').length <= MAX_MOST_USED_PLUGINS) {
×
UNCOV
1209
            return plugins;
×
1210
        }
1211

UNCOV
1212
        let ref = plugins.find('.cms-quicksearch');
×
1213

1214
        mostUsedPlugins.forEach(([name]) => {
×
1215
            if (count === MAX_MOST_USED_PLUGINS) {
×
UNCOV
1216
                return;
×
1217
            }
UNCOV
1218
            const item = items.find(`[href=${name}]`);
×
1219

1220
            if (item.length) {
×
UNCOV
1221
                const clone = item.closest('.cms-submenu-item').clone(true, true);
×
1222

1223
                ref.after(clone);
×
1224
                ref = clone;
×
UNCOV
1225
                count += 1;
×
1226
            }
1227
        });
1228

1229
        if (count) {
×
UNCOV
1230
            plugins.find('.cms-quicksearch').after(
×
1231
                $(`<div class="cms-submenu-item cms-submenu-item-title" data-cms-most-used>
1232
                    <span>${CMS.config.lang.mostUsed}</span>
1233
                </div>`)
1234
            );
1235
        }
1236

UNCOV
1237
        return plugins;
×
1238
    },
1239

1240
    /**
1241
     * Returns a specific plugin namespaced event postfixing the plugin uid to it
1242
     * in order to properly manage it via jQuery $.on and $.off
1243
     *
1244
     * @method _getNamepacedEvent
1245
     * @private
1246
     * @param {String} base - plugin event type
1247
     * @param {String} additionalNS - additional namespace (like '.traverse' for example)
1248
     * @returns {String} a specific plugin event
1249
     *
1250
     * @example
1251
     *
1252
     * plugin._getNamepacedEvent(Plugin.click); // 'click.cms.plugin.42'
1253
     * plugin._getNamepacedEvent(Plugin.keyDown, '.traverse'); // 'keydown.cms.plugin.traverse.42'
1254
     */
1255
    _getNamepacedEvent(base, additionalNS = '') {
133✔
1256
        return `${base}${additionalNS ? '.'.concat(additionalNS) : ''}.${this.uid}`;
144✔
1257
    },
1258

1259
    /**
1260
     * Returns available plugin/placeholder child classes markup
1261
     * for "Add plugin" modal
1262
     *
1263
     * @method _getPossibleChildClasses
1264
     * @private
1265
     * @returns {jQuery} "add plugin" menu
1266
     */
1267
    _getPossibleChildClasses: function _getPossibleChildClasses() {
1268
        var that = this;
33✔
1269
        var childRestrictions = this.options.plugin_restriction;
33✔
1270
        // have to check the placeholder every time, since plugin could've been
1271
        // moved as part of another plugin
1272
        var placeholderId = that._getId(that.ui.submenu.closest('.cms-dragarea'));
33✔
1273
        var resultElements = $($('#cms-plugin-child-classes-' + placeholderId).html());
33✔
1274

1275
        if (childRestrictions && childRestrictions.length) {
33✔
1276
            resultElements = resultElements.filter(function() {
29✔
1277
                var item = $(this);
4,727✔
1278

1279
                return (
4,727✔
1280
                    item.hasClass('cms-submenu-item-title') ||
9,106✔
1281
                    childRestrictions.indexOf(item.find('a').attr('href')) !== -1
1282
                );
1283
            });
1284

1285
            resultElements = resultElements.filter(function(index) {
29✔
1286
                var item = $(this);
411✔
1287

1288
                return (
411✔
1289
                    !item.hasClass('cms-submenu-item-title') ||
1,182✔
1290
                    (item.hasClass('cms-submenu-item-title') &&
1291
                        (!resultElements.eq(index + 1).hasClass('cms-submenu-item-title') &&
1292
                            resultElements.eq(index + 1).length))
1293
                );
1294
            });
1295
        }
1296

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

1299
        return resultElements;
33✔
1300
    },
1301

1302
    /**
1303
     * Sets up event handlers for quicksearching in the plugin picker.
1304
     *
1305
     * @method _setupQuickSearch
1306
     * @private
1307
     * @param {jQuery} plugins plugins picker element
1308
     */
1309
    _setupQuickSearch: function _setupQuickSearch(plugins) {
1310
        var that = this;
×
1311
        var FILTER_DEBOUNCE_TIMER = 100;
×
UNCOV
1312
        var FILTER_PICK_DEBOUNCE_TIMER = 110;
×
1313

1314
        var handler = debounce(function() {
×
UNCOV
1315
            var input = $(this);
×
1316
            // have to always find the pluginsPicker in the handler
1317
            // because of how we move things into/out of the modal
UNCOV
1318
            var pluginsPicker = input.closest('.cms-plugin-picker');
×
1319

UNCOV
1320
            that._filterPluginsList(pluginsPicker, input);
×
1321
        }, FILTER_DEBOUNCE_TIMER);
1322

UNCOV
1323
        plugins.find('> .cms-quicksearch').find('input').on(Plugin.keyUp, handler).on(
×
1324
            Plugin.keyUp,
1325
            debounce(function(e) {
1326
                var input;
1327
                var pluginsPicker;
1328

1329
                if (e.keyCode === KEYS.ENTER) {
×
1330
                    input = $(this);
×
1331
                    pluginsPicker = input.closest('.cms-plugin-picker');
×
UNCOV
1332
                    pluginsPicker
×
1333
                        .find('.cms-submenu-item')
1334
                        .not('.cms-submenu-item-title')
1335
                        .filter(':visible')
1336
                        .first()
1337
                        .find('> a')
1338
                        .focus()
1339
                        .trigger('click');
1340
                }
1341
            }, FILTER_PICK_DEBOUNCE_TIMER)
1342
        );
1343
    },
1344

1345
    /**
1346
     * Sets up click handlers for various plugin/placeholder items.
1347
     * Items can be anywhere in the plugin dragitem, not only in dropdown.
1348
     *
1349
     * @method _setupActions
1350
     * @private
1351
     * @param {jQuery} nav dropdown trigger with the items
1352
     */
1353
    _setupActions: function _setupActions(nav) {
1354
        var items = '.cms-submenu-edit, .cms-submenu-item a';
163✔
1355
        var parent = nav.parent();
163✔
1356

1357
        parent.find('.cms-submenu-edit').off(Plugin.touchStart).on(Plugin.touchStart, function(e) {
163✔
1358
            // required on some touch devices so
1359
            // ui touch punch is not triggering mousemove
1360
            // which in turn results in pep triggering pointercancel
1361
            e.stopPropagation();
1✔
1362
        });
1363
        parent.find(items).off(Plugin.click).on(Plugin.click, nav, e => this._delegate(e));
163✔
1364
    },
1365

1366
    /**
1367
     * Handler for the "action" items
1368
     *
1369
     * @method _delegate
1370
     * @param {$.Event} e event
1371
     * @private
1372
     */
1373
    // eslint-disable-next-line complexity
1374
    _delegate: function _delegate(e) {
1375
        e.preventDefault();
13✔
1376
        e.stopPropagation();
13✔
1377

1378
        var nav;
1379
        var that = this;
13✔
1380

1381
        if (e.data && e.data.nav) {
13!
UNCOV
1382
            nav = e.data.nav;
×
1383
        }
1384

1385
        // show loader and make sure scroll doesn't jump
1386
        showLoader();
13✔
1387

1388
        var items = '.cms-submenu-edit, .cms-submenu-item a';
13✔
1389
        var el = $(e.target).closest(items);
13✔
1390

1391
        Plugin._hideSettingsMenu(nav);
13✔
1392

1393
        // set switch for subnav entries
1394
        switch (el.attr('data-rel')) {
13!
1395

1396
            case 'add': {
1397
                const pluginType = el.attr('href').replace('#', '');
2✔
1398
                const showAddForm = el.data('addForm');
2✔
1399

1400
                Plugin._updateUsageCount(pluginType);
2✔
1401
                that.addPlugin(pluginType, el.text(), el.closest('.cms-plugin-picker').data('parentId'), showAddForm);
2✔
1402
                break;
2✔
1403
            }
1404
            case 'ajax_add':
1405
                CMS.API.Toolbar.openAjax({
1✔
1406
                    url: el.attr('href'),
1407
                    post: JSON.stringify(el.data('post')),
1408
                    text: el.data('text'),
1409
                    callback: $.proxy(that.editPluginPostAjax, that),
1410
                    onSuccess: el.data('on-success')
1411
                });
1412
                break;
1✔
1413
            case 'edit':
1414
                that.editPlugin(
1✔
1415
                    Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
1416
                    that.options.plugin_name,
1417
                    that._getPluginBreadcrumbs()
1418
                );
1419
                break;
1✔
1420
            case 'copy-lang':
1421
                that.copyPlugin(that.options, el.attr('data-language'));
1✔
1422
                break;
1✔
1423
            case 'copy':
1424
                if (el.parent().hasClass('cms-submenu-item-disabled')) {
2✔
1425
                    hideLoader();
1✔
1426
                } else {
1427
                    that.copyPlugin();
1✔
1428
                }
1429
                break;
2✔
1430
            case 'cut':
1431
                that.cutPlugin();
1✔
1432
                break;
1✔
1433
            case 'paste':
1434
                hideLoader();
2✔
1435
                if (!el.parent().hasClass('cms-submenu-item-disabled')) {
2✔
1436
                    that.pastePlugin();
1✔
1437
                }
1438
                break;
2✔
1439
            case 'delete':
1440
                that.deletePlugin(
1✔
1441
                    Helpers.updateUrlWithPath(that.options.urls.delete_plugin),
1442
                    that.options.plugin_name,
1443
                    that._getPluginBreadcrumbs()
1444
                );
1445
                break;
1✔
1446
            case 'highlight':
1447
                hideLoader();
×
1448

1449
                window.location.hash = `cms-plugin-${this.options.plugin_id}`;
×
1450
                Plugin._highlightPluginContent(this.options.plugin_id, { seeThrough: true });
×
1451
                e.stopImmediatePropagation();
×
1452
                break;
×
1453
            default:
1454
                hideLoader();
2✔
1455
                CMS.API.Toolbar._delegate(el);
2✔
1456
        }
1457
    },
1458

1459
    /**
1460
     * Sets up keyboard traversing of plugin picker.
1461
     *
1462
     * @method _setupKeyboardTraversing
1463
     * @private
1464
     */
1465
    _setupKeyboardTraversing: function _setupKeyboardTraversing() {
1466
        var dropdown = $('.cms-modal-markup .cms-plugin-picker');
3✔
1467
        const keyDownTraverseEvent = this._getNamepacedEvent(Plugin.keyDown, 'traverse');
3✔
1468

1469
        if (!dropdown.length) {
3✔
1470
            return;
1✔
1471
        }
1472
        // add key events
1473
        $document.off(keyDownTraverseEvent);
2✔
1474
        // istanbul ignore next: not really possible to reproduce focus state in unit tests
1475
        $document.on(keyDownTraverseEvent, function(e) {
1476
            var anchors = dropdown.find('.cms-submenu-item:visible a');
1477
            var index = anchors.index(anchors.filter(':focus'));
1478

1479
            // bind arrow down and tab keys
1480
            if (e.keyCode === KEYS.DOWN || (e.keyCode === KEYS.TAB && !e.shiftKey)) {
1481
                e.preventDefault();
1482
                if (index >= 0 && index < anchors.length - 1) {
1483
                    anchors.eq(index + 1).focus();
1484
                } else {
1485
                    anchors.eq(0).focus();
1486
                }
1487
            }
1488

1489
            // bind arrow up and shift+tab keys
1490
            if (e.keyCode === KEYS.UP || (e.keyCode === KEYS.TAB && e.shiftKey)) {
1491
                e.preventDefault();
1492
                if (anchors.is(':focus')) {
1493
                    anchors.eq(index - 1).focus();
1494
                } else {
1495
                    anchors.eq(anchors.length).focus();
1496
                }
1497
            }
1498
        });
1499
    },
1500

1501
    /**
1502
     * Opens the settings menu for a plugin.
1503
     *
1504
     * @method _showSettingsMenu
1505
     * @private
1506
     * @param {jQuery} nav trigger element
1507
     */
1508
    _showSettingsMenu: function(nav) {
1509
        this._checkIfPasteAllowed();
×
1510

1511
        var dropdown = this.ui.dropdown;
×
1512
        var parents = nav.parentsUntil('.cms-dragarea').last();
×
1513
        var MIN_SCREEN_MARGIN = 10;
×
1514

1515
        nav.addClass('cms-btn-active');
×
1516
        parents.addClass('cms-z-index-9999');
×
1517

1518
        // set visible states
1519
        dropdown.show();
×
1520

1521
        // calculate dropdown positioning
1522
        if (
×
1523
            $window.height() + $window.scrollTop() - nav.offset().top - dropdown.height() <= MIN_SCREEN_MARGIN &&
×
1524
            nav.offset().top - dropdown.height() >= 0
1525
        ) {
1526
            dropdown.removeClass('cms-submenu-dropdown-top').addClass('cms-submenu-dropdown-bottom');
×
1527
        } else {
1528
            dropdown.removeClass('cms-submenu-dropdown-bottom').addClass('cms-submenu-dropdown-top');
×
1529
        }
1530
    },
1531

1532
    /**
1533
     * Filters given plugins list by a query.
1534
     *
1535
     * @method _filterPluginsList
1536
     * @private
1537
     * @param {jQuery} list plugins picker element
1538
     * @param {jQuery} input input, which value to filter plugins with
1539
     * @returns {Boolean|void}
1540
     */
1541
    _filterPluginsList: function _filterPluginsList(list, input) {
1542
        var items = list.find('.cms-submenu-item');
5✔
1543
        var titles = list.find('.cms-submenu-item-title');
5✔
1544
        var query = input.val();
5✔
1545

1546
        // cancel if query is zero
1547
        if (query === '') {
5✔
1548
            items.add(titles).show();
1✔
1549
            return false;
1✔
1550
        }
1551

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

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

1556
        // Simple case-insensitive substring matching (replaces fuzzyFilter)
1557
        var queryLower = query.toLowerCase();
4✔
1558

1559
        items.hide();
4✔
1560
        items.each(function() {
4✔
1561
            var item = $(this);
72✔
1562
            var text = item.text().toLowerCase();
72✔
1563

1564
            if (text.indexOf(queryLower) !== -1) {
72✔
1565
                item.show();
3✔
1566
            }
1567
        });
1568

1569
        // check if a title is matching
1570
        titles.filter(':visible').each(function(index, item) {
4✔
1571
            titles.hide();
1✔
1572
            $(item).nextUntil('.cms-submenu-item-title').show();
1✔
1573
        });
1574

1575
        // always display title of a category
1576
        items.filter(':visible').each(function(index, titleItem) {
4✔
1577
            var item = $(titleItem);
16✔
1578

1579
            if (item.prev().hasClass('cms-submenu-item-title')) {
16✔
1580
                item.prev().show();
2✔
1581
            } else {
1582
                item.prevUntil('.cms-submenu-item-title').last().prev().show();
14✔
1583
            }
1584
        });
1585

1586
        mostRecentItems.hide();
4✔
1587
    },
1588

1589
    /**
1590
     * Toggles collapsable item.
1591
     *
1592
     * @method _toggleCollapsable
1593
     * @private
1594
     * @param {jQuery} el element to toggle
1595
     * @returns {Boolean|void}
1596
     */
1597
    _toggleCollapsable: function toggleCollapsable(el) {
UNCOV
1598
        var that = this;
×
UNCOV
1599
        var id = that._getId(el.parent());
×
1600
        var draggable = el.closest('.cms-draggable');
×
1601
        var items;
1602

UNCOV
1603
        var settings = CMS.settings;
×
1604

1605
        settings.states = settings.states || [];
×
1606

UNCOV
1607
        if (!draggable || !draggable.length) {
×
UNCOV
1608
            return;
×
1609
        }
1610

1611
        // collapsable function and save states
UNCOV
1612
        if (el.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1613
            settings.states.splice($.inArray(id, settings.states), 1);
×
UNCOV
1614
            el
×
1615
                .removeClass('cms-dragitem-expanded')
1616
                .parent()
1617
                .find('> .cms-collapsable-container')
1618
                .addClass('cms-hidden');
1619

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

UNCOV
1628
                    if (item.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1629
                        that._toggleCollapsable(item);
×
1630
                    }
1631
                });
1632
            }
1633
        } else {
UNCOV
1634
            settings.states.push(id);
×
UNCOV
1635
            el
×
1636
                .addClass('cms-dragitem-expanded')
1637
                .parent()
1638
                .find('> .cms-collapsable-container')
1639
                .removeClass('cms-hidden');
1640

1641
            if ($document.data('expandmode')) {
×
UNCOV
1642
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1643
                if (!items.length) {
×
1644
                    return false;
×
1645
                }
1646
                items.each(function() {
×
1647
                    var item = $(this);
×
1648

UNCOV
1649
                    if (!item.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1650
                        that._toggleCollapsable(item);
×
1651
                    }
1652
                });
1653
            }
1654
        }
1655

1656
        this._updatePlaceholderCollapseState();
×
1657

1658
        // make sure structurboard gets updated after expanding
1659
        $document.trigger('resize.sideframe');
×
1660

1661
        // save settings
UNCOV
1662
        Helpers.setSettings(settings);
×
1663
    },
1664

1665
    _updatePlaceholderCollapseState() {
UNCOV
1666
        if (this.options.type !== 'plugin' || !this.options.placeholder_id) {
×
1667
            return;
×
1668
        }
1669

UNCOV
1670
        const pluginsOfCurrentPlaceholder = CMS._plugins
×
1671
            .filter(([, o]) => o.placeholder_id === this.options.placeholder_id && o.type === 'plugin')
×
1672
            .map(([, o]) => o.plugin_id);
×
1673

1674
        const openedPlugins = CMS.settings.states;
×
UNCOV
1675
        const closedPlugins = difference(pluginsOfCurrentPlaceholder, openedPlugins);
×
1676
        const areAllRemainingPluginsLeafs = every(closedPlugins, id => {
×
UNCOV
1677
            return !find(
×
1678
                CMS._plugins,
1679
                ([, o]) => o.placeholder_id === this.options.placeholder_id && o.plugin_parent === id
×
1680
            );
1681
        });
1682
        const el = $(`.cms-dragarea-${this.options.placeholder_id} .cms-dragbar-title`);
×
UNCOV
1683
        var settings = CMS.settings;
×
1684

UNCOV
1685
        if (areAllRemainingPluginsLeafs) {
×
1686
            // meaning that all plugins in current placeholder are expanded
1687
            el.addClass('cms-dragbar-title-expanded');
×
1688

1689
            settings.dragbars = settings.dragbars || [];
×
UNCOV
1690
            settings.dragbars.push(this.options.placeholder_id);
×
1691
        } else {
1692
            el.removeClass('cms-dragbar-title-expanded');
×
1693

UNCOV
1694
            settings.dragbars = settings.dragbars || [];
×
UNCOV
1695
            settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1696
        }
1697
    },
1698

1699
    /**
1700
     * Sets up collabspable event handlers.
1701
     *
1702
     * @method _collapsables
1703
     * @private
1704
     * @returns {Boolean|void}
1705
     */
1706
    _collapsables: function() {
1707
        // one time setup
1708
        var that = this;
153✔
1709

1710
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
153✔
1711
        // cancel here if its not a draggable
1712
        if (!this.ui.draggable.length) {
153✔
1713
            return false;
38✔
1714
        }
1715

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

1718
        // check which button should be shown for collapsemenu
1719
        var els = this.ui.draggable.find('.cms-dragitem-collapsable');
115✔
1720
        var open = els.filter('.cms-dragitem-expanded');
115✔
1721

1722
        if (els.length === open.length && els.length + open.length !== 0) {
115!
UNCOV
1723
            this.ui.draggable.find('.cms-dragbar-title').addClass('cms-dragbar-title-expanded');
×
1724
        }
1725

1726
        // attach events to draggable
1727
        // debounce here required because on some devices click is not triggered,
1728
        // so we consolidate latest click and touch event to run the collapse only once
1729
        dragitem.find('> .cms-dragitem-text').on(
115✔
1730
            Plugin.touchEnd + ' ' + Plugin.click,
1731
            debounce(function() {
1732
                if (!dragitem.hasClass('cms-dragitem-collapsable')) {
×
UNCOV
1733
                    return;
×
1734
                }
UNCOV
1735
                that._toggleCollapsable(dragitem);
×
1736
            }, 0)
1737
        );
1738
    },
1739

1740
    /**
1741
     * Expands all the collapsables in the given placeholder.
1742
     *
1743
     * @method _expandAll
1744
     * @private
1745
     * @param {jQuery} el trigger element that is a child of a placeholder
1746
     * @returns {Boolean|void}
1747
     */
1748
    _expandAll: function(el) {
UNCOV
1749
        var that = this;
×
1750
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1751

1752
        // cancel if there are no items
1753
        if (!items.length) {
×
1754
            return false;
×
1755
        }
1756
        items.each(function() {
×
1757
            var item = $(this);
×
1758

UNCOV
1759
            if (!item.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1760
                that._toggleCollapsable(item);
×
1761
            }
1762
        });
1763

UNCOV
1764
        el.addClass('cms-dragbar-title-expanded');
×
1765

1766
        var settings = CMS.settings;
×
1767

UNCOV
1768
        settings.dragbars = settings.dragbars || [];
×
UNCOV
1769
        settings.dragbars.push(this.options.placeholder_id);
×
UNCOV
1770
        Helpers.setSettings(settings);
×
1771
    },
1772

1773
    /**
1774
     * Collapses all the collapsables in the given placeholder.
1775
     *
1776
     * @method _collapseAll
1777
     * @private
1778
     * @param {jQuery} el trigger element that is a child of a placeholder
1779
     */
1780
    _collapseAll: function(el) {
1781
        var that = this;
×
1782
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1783

1784
        items.each(function() {
×
1785
            var item = $(this);
×
1786

UNCOV
1787
            if (item.hasClass('cms-dragitem-expanded')) {
×
UNCOV
1788
                that._toggleCollapsable(item);
×
1789
            }
1790
        });
1791

UNCOV
1792
        el.removeClass('cms-dragbar-title-expanded');
×
1793

1794
        var settings = CMS.settings;
×
1795

UNCOV
1796
        settings.dragbars = settings.dragbars || [];
×
UNCOV
1797
        settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
UNCOV
1798
        Helpers.setSettings(settings);
×
1799
    },
1800

1801
    /**
1802
     * Gets the id of the element, uses CMS.StructureBoard instance.
1803
     *
1804
     * @method _getId
1805
     * @private
1806
     * @param {jQuery} el element to get id from
1807
     * @returns {String}
1808
     */
1809
    _getId: function(el) {
1810
        return CMS.API.StructureBoard.getId(el);
36✔
1811
    },
1812

1813
    /**
1814
     * Gets the ids of the list of elements, uses CMS.StructureBoard instance.
1815
     *
1816
     * @method _getIds
1817
     * @private
1818
     * @param {jQuery} els elements to get id from
1819
     * @returns {String[]}
1820
     */
1821
    _getIds: function(els) {
UNCOV
1822
        return CMS.API.StructureBoard.getIds(els);
×
1823
    },
1824

1825
    /**
1826
     * Traverses the registry to find plugin parents
1827
     *
1828
     * @method _getPluginBreadcrumbs
1829
     * @returns {Object[]} array of breadcrumbs in `{ url, title }` format
1830
     * @private
1831
     */
1832
    _getPluginBreadcrumbs: function _getPluginBreadcrumbs() {
1833
        var breadcrumbs = [];
6✔
1834

1835
        breadcrumbs.unshift({
6✔
1836
            title: this.options.plugin_name,
1837
            url: this.options.urls.edit_plugin
1838
        });
1839

1840
        var findParentPlugin = function(id) {
6✔
1841
            return $.grep(CMS._plugins || [], function(pluginOptions) {
6✔
1842
                return pluginOptions[0] === 'cms-plugin-' + id;
10✔
1843
            })[0];
1844
        };
1845

1846
        var id = this.options.plugin_parent;
6✔
1847
        var data;
1848

1849
        while (id && id !== 'None') {
6✔
1850
            data = findParentPlugin(id);
6✔
1851

1852
            if (!data) {
6✔
1853
                break;
1✔
1854
            }
1855

1856
            breadcrumbs.unshift({
5✔
1857
                title: data[1].plugin_name,
1858
                url: data[1].urls.edit_plugin
1859
            });
1860
            id = data[1].plugin_parent;
5✔
1861
        }
1862

1863
        return breadcrumbs;
6✔
1864
    }
1865
});
1866

1867
Plugin.click = 'click.cms.plugin';
1✔
1868
Plugin.pointerUp = 'pointerup.cms.plugin';
1✔
1869
Plugin.pointerDown = 'pointerdown.cms.plugin';
1✔
1870
Plugin.pointerOverAndOut = 'pointerover.cms.plugin pointerout.cms.plugin';
1✔
1871
Plugin.doubleClick = 'dblclick.cms.plugin';
1✔
1872
Plugin.keyUp = 'keyup.cms.plugin';
1✔
1873
Plugin.keyDown = 'keydown.cms.plugin';
1✔
1874
Plugin.mouseEvents = 'mousedown.cms.plugin mousemove.cms.plugin mouseup.cms.plugin';
1✔
1875
Plugin.touchStart = 'touchstart.cms.plugin';
1✔
1876
Plugin.touchEnd = 'touchend.cms.plugin';
1✔
1877

1878
/**
1879
 * Updates plugin data in CMS._plugins / CMS._instances or creates new
1880
 * plugin instances if they didn't exist
1881
 *
1882
 * @method _updateRegistry
1883
 * @private
1884
 * @static
1885
 * @param {Object[]} plugins plugins data
1886
 */
1887
Plugin._updateRegistry = function _updateRegistry(plugins) {
1✔
UNCOV
1888
    plugins.forEach(pluginData => {
×
1889
        const pluginContainer = `cms-plugin-${pluginData.plugin_id}`;
×
1890
        const pluginIndex = findIndex(CMS._plugins, ([pluginStr]) => pluginStr === pluginContainer);
×
1891

UNCOV
1892
        if (pluginIndex === -1) {
×
1893
            CMS._plugins.push([pluginContainer, pluginData]);
×
1894
            CMS._instances.push(new Plugin(pluginContainer, pluginData));
×
1895
        } else {
UNCOV
1896
            Plugin.aliasPluginDuplicatesMap[pluginData.plugin_id] = false;
×
UNCOV
1897
            CMS._plugins[pluginIndex] = [pluginContainer, pluginData];
×
UNCOV
1898
            CMS._instances[pluginIndex] = new Plugin(pluginContainer, pluginData);
×
1899
        }
1900
    });
1901
};
1902

1903
/**
1904
 * Hides the opened settings menu. By default looks for any open ones.
1905
 *
1906
 * @method _hideSettingsMenu
1907
 * @static
1908
 * @private
1909
 * @param {jQuery} [navEl] element representing the subnav trigger
1910
 */
1911
Plugin._hideSettingsMenu = function(navEl) {
1✔
1912
    var nav = navEl || $('.cms-submenu-btn.cms-btn-active');
20✔
1913

1914
    if (!nav.length) {
20!
1915
        return;
20✔
1916
    }
1917
    nav.removeClass('cms-btn-active');
×
1918

1919
    // set correct active state
1920
    nav.closest('.cms-draggable').data('active', false);
×
1921
    $('.cms-z-index-9999').removeClass('cms-z-index-9999');
×
1922

1923
    nav.siblings('.cms-submenu-dropdown').hide();
×
UNCOV
1924
    nav.siblings('.cms-quicksearch').hide();
×
1925
    // reset search
1926
    nav.siblings('.cms-quicksearch').find('input').val('').trigger(Plugin.keyUp).blur();
×
1927

1928
    // reset relativity
UNCOV
1929
    $('.cms-dragbar').css('position', '');
×
1930
};
1931

1932
/**
1933
 * Initialises handlers that affect all plugins and don't make sense
1934
 * in context of each own plugin instance, e.g. listening for a click on a document
1935
 * to hide plugin settings menu should only be applied once, and not every time
1936
 * CMS.Plugin is instantiated.
1937
 *
1938
 * @method _initializeGlobalHandlers
1939
 * @static
1940
 * @private
1941
 */
1942
Plugin._initializeGlobalHandlers = function _initializeGlobalHandlers() {
1✔
1943
    var timer;
1944
    var clickCounter = 0;
6✔
1945

1946
    Plugin._updateClipboard();
6✔
1947

1948
    // Structureboard initialized too late
1949
    setTimeout(function() {
6✔
1950
        var pluginData = {};
6✔
1951
        var html = '';
6✔
1952

1953
        if (clipboardDraggable.length) {
6✔
1954
            pluginData = find(
5✔
1955
                CMS._plugins,
1956
                ([desc]) => desc === `cms-plugin-${CMS.API.StructureBoard.getId(clipboardDraggable)}`
10✔
1957
            )[1];
1958
            html = clipboardDraggable.parent().html();
5✔
1959
        }
1960
        if (CMS.API && CMS.API.Clipboard) {
6!
1961
            CMS.API.Clipboard.populate(html, pluginData);
6✔
1962
        }
1963
    }, 0);
1964

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

1996
            // prevents single click from messing up the edit call
1997
            // don't go to the link if there is custom js attached to it
1998
            // or if it's clicked along with shift, ctrl, cmd
1999
            if (e.shiftKey || e.ctrlKey || e.metaKey || e.isDefaultPrevented()) {
×
2000
                return;
×
2001
            }
2002
            e.preventDefault();
×
UNCOV
2003
            if (++clickCounter === 1) {
×
2004
                timer = setTimeout(function() {
×
2005
                    var anchor = $(e.target).closest('a');
×
2006

UNCOV
2007
                    clickCounter = 0;
×
2008
                    window.open(anchor.attr('href'), anchor.attr('target') || '_self');
×
2009
                }, DOUBLECLICK_DELAY);
2010
            } else {
UNCOV
2011
                clearTimeout(timer);
×
UNCOV
2012
                clickCounter = 0;
×
2013
            }
2014
        });
2015

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

UNCOV
2025
        e.stopPropagation();
×
2026
        const pluginContainer = $(e.target).closest('.cms-plugin');
×
2027
        const allOptions = pluginContainer.data('cms');
×
2028

UNCOV
2029
        if (!allOptions || !allOptions.length) {
×
2030
            return;
×
2031
        }
2032

2033
        const options = allOptions[0];
×
2034

2035
        if (e.type === 'touchstart') {
×
2036
            CMS.API.Tooltip._forceTouchOnce();
×
2037
        }
UNCOV
2038
        var name = options.plugin_name;
×
2039
        var id = options.plugin_id;
×
2040
        var type = options.type;
×
2041

2042
        if (type === 'generic') {
×
2043
            return;
×
2044
        }
2045
        var placeholderId = CMS.API.StructureBoard.getId($(`.cms-draggable-${id}`).closest('.cms-dragarea'));
×
2046
        var placeholder = $('.cms-placeholder-' + placeholderId);
×
2047

UNCOV
2048
        if (placeholder.length && placeholder.data('cms')) {
×
2049
            name = placeholder.data('cms').name + ': ' + name;
×
2050
        }
2051

UNCOV
2052
        CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
2053
    });
2054

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

UNCOV
2058
        if (placeholder.hasClass('cms-dragarea-static-expanded') && e.isDefaultPrevented()) {
×
2059
            return;
×
2060
        }
2061

UNCOV
2062
        placeholder.toggleClass('cms-dragarea-static-expanded');
×
2063
    });
2064

2065
    $window.on('blur.cms', () => {
6✔
2066
        $document.data('expandmode', false);
6✔
2067
    });
2068
};
2069

2070
/**
2071
 * @method _isContainingMultiplePlugins
2072
 * @param {jQuery} node to check
2073
 * @static
2074
 * @private
2075
 * @returns {Boolean}
2076
 */
2077
Plugin._isContainingMultiplePlugins = function _isContainingMultiplePlugins(node) {
1✔
2078
    var currentData = node.data('cms');
130✔
2079

2080
    // istanbul ignore if
2081
    if (!currentData) {
130✔
2082
        throw new Error('Provided node is not a cms plugin.');
2083
    }
2084

2085
    var pluginIds = currentData.map(function(pluginData) {
130✔
2086
        return pluginData.plugin_id;
131✔
2087
    });
2088

2089
    if (pluginIds.length > 1) {
130✔
2090
        // another plugin already lives on the same node
2091
        // this only works because the plugins are rendered from
2092
        // the bottom to the top (leaf to root)
2093
        // meaning the deepest plugin is always first
2094
        return true;
1✔
2095
    }
2096

2097
    return false;
129✔
2098
};
2099

2100
/**
2101
 * Shows and immediately fades out a success notification (when
2102
 * plugin was successfully moved.
2103
 *
2104
 * @method _highlightPluginStructure
2105
 * @private
2106
 * @static
2107
 * @param {jQuery} el draggable element
2108
 */
2109

2110
Plugin._highlightPluginStructure = function _highlightPluginStructure(
1✔
2111
    el,
2112
    // eslint-disable-next-line no-magic-numbers
2113
    { successTimeout = 200, delay = 1500, seeThrough = false }
×
2114
) {
UNCOV
2115
    const tpl = $(`
×
2116
        <div class="cms-dragitem-success ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}">
×
2117
        </div>
2118
    `);
2119

2120
    el.addClass('cms-draggable-success').append(tpl);
×
2121
    // start animation
2122
    if (successTimeout) {
×
2123
        setTimeout(() => {
×
UNCOV
2124
            tpl.fadeOut(successTimeout, function() {
×
UNCOV
2125
                $(this).remove();
×
UNCOV
2126
                el.removeClass('cms-draggable-success');
×
2127
            });
2128
        }, delay);
2129
    }
2130
    // make sure structurboard gets updated after success
UNCOV
2131
    $(Helpers._getWindow()).trigger('resize.sideframe');
×
2132
};
2133

2134
/**
2135
 * Highlights plugin in content mode
2136
 *
2137
 * @method _highlightPluginContent
2138
 * @private
2139
 * @static
2140
 * @param {String|Number} pluginId
2141
 */
2142
/* eslint-disable complexity, no-magic-numbers */
2143
Plugin._highlightPluginContent = function _highlightPluginContent(
1✔
2144
    pluginId,
2145
    { successTimeout = 200, seeThrough = false, delay = 1500, prominent = false } = {}
5✔
2146
) {
2147
    var coordinates = {};
1✔
2148
    var positions = [];
1✔
2149
    var OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO = 0.2;
1✔
2150

2151
    $('.cms-plugin-' + pluginId).each(function() {
1✔
2152
        var el = $(this);
1✔
2153
        var offset = el.offset();
1✔
2154
        var ml = parseInt(el.css('margin-left'), 10);
1✔
2155
        var mr = parseInt(el.css('margin-right'), 10);
1✔
2156
        var mt = parseInt(el.css('margin-top'), 10);
1✔
2157
        var mb = parseInt(el.css('margin-bottom'), 10);
1✔
2158
        var width = el.outerWidth();
1✔
2159
        var height = el.outerHeight();
1✔
2160

2161
        if (width === 0 && height === 0) {
1!
UNCOV
2162
            return;
×
2163
        }
2164

2165
        if (isNaN(ml)) {
1!
2166
            ml = 0;
×
2167
        }
2168
        if (isNaN(mr)) {
1!
2169
            mr = 0;
×
2170
        }
2171
        if (isNaN(mt)) {
1!
2172
            mt = 0;
×
2173
        }
2174
        if (isNaN(mb)) {
1!
UNCOV
2175
            mb = 0;
×
2176
        }
2177

2178
        positions.push({
1✔
2179
            x1: offset.left - ml,
2180
            x2: offset.left + width + mr,
2181
            y1: offset.top - mt,
2182
            y2: offset.top + height + mb
2183
        });
2184
    });
2185

2186
    if (positions.length === 0) {
1!
UNCOV
2187
        return;
×
2188
    }
2189

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

2195
    coordinates.left = Math.min(...positions.map(pos => pos.x1));
1✔
2196
    coordinates.top = Math.min(...positions.map(pos => pos.y1)) - htmlMargin;
1✔
2197
    coordinates.width = Math.max(...positions.map(pos => pos.x2)) - coordinates.left;
1✔
2198
    coordinates.height = Math.max(...positions.map(pos => pos.y2)) - coordinates.top - htmlMargin;
1✔
2199

2200
    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);
1✔
2201

2202
    $(
1✔
2203
        `
2204
        <div class="
2205
            cms-plugin-overlay
2206
            cms-dragitem-success
2207
            cms-plugin-overlay-${pluginId}
2208
            ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}
1!
2209
            ${prominent ? 'cms-plugin-overlay-prominent' : ''}
1!
2210
        "
2211
            data-success-timeout="${successTimeout}"
2212
        >
2213
        </div>
2214
    `
2215
    )
2216
        .css(coordinates)
2217
        .css({
2218
            zIndex: 9999
2219
        })
2220
        .appendTo($('body'));
2221

2222
    if (successTimeout) {
1!
2223
        setTimeout(() => {
1✔
2224
            $(`.cms-plugin-overlay-${pluginId}`).fadeOut(successTimeout, function() {
1✔
2225
                $(this).remove();
1✔
2226
            });
2227
        }, delay);
2228
    }
2229
};
2230

2231
Plugin._clickToHighlightHandler = function _clickToHighlightHandler() {
1✔
UNCOV
2232
    if (CMS.settings.mode !== 'structure') {
×
2233
        return;
×
2234
    }
2235
    // FIXME refactor into an object
NEW
2236
    CMS.API.StructureBoard._showAndHighlightPlugin(200, true);
×
2237
};
2238

2239
Plugin._removeHighlightPluginContent = function(pluginId) {
1✔
UNCOV
2240
    $(`.cms-plugin-overlay-${pluginId}[data-success-timeout=0]`).remove();
×
2241
};
2242

2243
Plugin.aliasPluginDuplicatesMap = {};
1✔
2244
Plugin.staticPlaceholderDuplicatesMap = {};
1✔
2245

2246
// istanbul ignore next
2247
Plugin._initializeTree = function _initializeTree() {
2248
    const plugins = {};
2249

2250
    document.body.querySelectorAll(
2251
        'script[data-cms-plugin], ' +
2252
        'script[data-cms-placeholder], ' +
2253
        'script[data-cms-general]'
2254
    ).forEach(script => {
2255
        plugins[script.id] = JSON.parse(script.textContent || '{}');
2256
    });
2257

2258
    CMS._plugins = Object.entries(plugins);
2259
    CMS._instances = CMS._plugins.map(function(args) {
2260
        return new CMS.Plugin(args[0], args[1]);
2261
    });
2262

2263
    // return the cms plugin instances just created
2264
    return CMS._instances;
2265
};
2266
/* eslint-enable complexity, no-magic-numbers */
2267

2268
Plugin._updateClipboard = function _updateClipboard() {
1✔
2269
    clipboardDraggable = $('.cms-draggable-from-clipboard:first');
7✔
2270
};
2271

2272
Plugin._updateUsageCount = function _updateUsageCount(pluginType) {
1✔
2273
    var currentValue = pluginUsageMap[pluginType] || 0;
2✔
2274

2275
    pluginUsageMap[pluginType] = currentValue + 1;
2✔
2276

2277
    if (Helpers._isStorageSupported) {
2!
UNCOV
2278
        localStorage.setItem('cms-plugin-usage', JSON.stringify(pluginUsageMap));
×
2279
    }
2280
};
2281

2282
Plugin._removeAddPluginPlaceholder = function removeAddPluginPlaceholder() {
1✔
2283
    // this can't be cached since they are created and destroyed all over the place
2284
    $('.cms-add-plugin-placeholder').remove();
10✔
2285
};
2286

2287
Plugin._refreshPlugins = function refreshPlugins() {
1✔
2288
    Plugin.aliasPluginDuplicatesMap = {};
4✔
2289
    Plugin.staticPlaceholderDuplicatesMap = {};
4✔
2290

2291
    // Re-read front-end editable fields ("general" plugins) from DOM
2292
    document.body.querySelectorAll('script[data-cms-general]').forEach(script => {
4✔
UNCOV
2293
        CMS._plugins.push([script.id, JSON.parse(script.textContent)]);
×
2294
    });
2295
    // Remove duplicates
2296
    CMS._plugins = uniqWith(CMS._plugins, isEqual);
4✔
2297

2298
    CMS._instances.forEach(instance => {
4✔
2299
        if (instance.options.type === 'placeholder') {
5✔
2300
            instance._setupUI(`cms-placeholder-${instance.options.placeholder_id}`);
2✔
2301
            instance._ensureData();
2✔
2302
            instance.ui.container.data('cms', instance.options);
2✔
2303
            instance._setPlaceholder();
2✔
2304
        }
2305
    });
2306

2307
    CMS._instances.forEach(instance => {
4✔
2308
        if (instance.options.type === 'plugin') {
5✔
2309
            instance._setupUI(`cms-plugin-${instance.options.plugin_id}`);
2✔
2310
            instance._ensureData();
2✔
2311
            instance.ui.container.data('cms').push(instance.options);
2✔
2312
            instance._setPluginContentEvents();
2✔
2313
        }
2314
    });
2315

2316
    CMS._plugins.forEach(([type, opts]) => {
4✔
2317
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2318
            const instance = find(
8✔
2319
                CMS._instances,
2320
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
13✔
2321
            );
2322

2323
            if (instance) {
8✔
2324
                // update
2325
                instance._setupUI(type);
1✔
2326
                instance._ensureData();
1✔
2327
                instance.ui.container.data('cms').push(instance.options);
1✔
2328
                instance._setGeneric();
1✔
2329
            } else {
2330
                // create
2331
                CMS._instances.push(new Plugin(type, opts));
7✔
2332
            }
2333
        }
2334
    });
2335
};
2336

2337
Plugin._getPluginById = function(id) {
1✔
2338
    return find(CMS._instances, ({ options }) => options.type === 'plugin' && Number(options.plugin_id) === Number(id));
20!
2339
};
2340

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

2346
    plugins.forEach((element, index) => {
10✔
2347
        const pluginId = CMS.API.StructureBoard.getId($(element));
20✔
2348
        const instance = Plugin._getPluginById(pluginId);
20✔
2349

2350
        if (!instance) {
20!
2351
            return;
20✔
2352
        }
2353

UNCOV
2354
        instance.options.position = index + 1;
×
2355
    });
2356
};
2357

2358
Plugin._recalculatePluginPositions = function(action, data) {
1✔
UNCOV
2359
    if (action === 'MOVE') {
×
2360
        // le sigh - recalculate all placeholders cause we don't know from where the
2361
        // plugin was moved from
2362
        filter(CMS._instances, ({ options }) => options.type === 'placeholder')
×
2363
            .map(({ options }) => options.placeholder_id)
×
2364
            .forEach(placeholder_id => Plugin._updatePluginPositions(placeholder_id));
×
UNCOV
2365
    } else if (data.placeholder_id) {
×
UNCOV
2366
        Plugin._updatePluginPositions(data.placeholder_id);
×
2367
    }
2368
};
2369

2370
// shorthand for jQuery(document).ready();
2371
$(Plugin._initializeGlobalHandlers);
1✔
2372

2373
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