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

divio / django-cms / #30101

12 Nov 2025 11:07AM UTC coverage: 90.532%. Remained the same
#30101

push

travis-ci

web-flow
Merge 12b8c6dbd into c38b75715

1306 of 2044 branches covered (63.89%)

294 of 321 new or added lines in 13 files covered. (91.59%)

458 existing lines in 7 files now uncovered.

9151 of 10108 relevant lines covered (90.53%)

11.16 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 nextUntil from './nextuntil';
8

9
import debounce from 'lodash-es/debounce.js';
10
import uniqWith from 'lodash-es/uniqWith.js';
11
import once from 'lodash-es/once.js';
12
import difference from 'lodash-es/difference.js';
13
import isEqual from 'lodash-es/isEqual.js';
14

15
import Class from 'classjs';
16
import { Helpers, KEYS, $window, $document, uid } from './cms.base';
17
import { showLoader, hideLoader } from './loader';
18

19
var clipboardDraggable;
20
var path = window.location.pathname + window.location.search;
1✔
21

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

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

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

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

62
    // these properties will be filled later
63
    modal: null,
64

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

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

71
        this._setupUI(container);
179✔
72
        this._ensureData();
179✔
73

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

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

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

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

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

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

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

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

155
        this.ui = this.ui || {};
179✔
156
        this.ui.container = contents;
179✔
157
    },
158

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

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

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

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

215
        itemContents = itemContents.filter(function() {
137✔
216
            return this.nodeType !== Node.TEXT_NODE && this.nodeType !== Node.COMMENT_NODE;
158✔
217
        });
218

219
        itemContents.addClass(`cms-plugin ${className}`);
137✔
220
        itemContents.first().addClass('cms-plugin-start');
137✔
221
        itemContents.last().addClass('cms-plugin-end');
137✔
222

223
        return itemContents;
137✔
224
    },
225

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

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

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

246
        // istanbul ignore next
247
        CMS.settings.dragbars = CMS.settings.dragbars || []; // expanded dragbars array
248

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

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

263
        this._checkIfPasteAllowed();
23✔
264
    },
265

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

281
    _setPluginStructureEvents: function _setPluginStructureEvents() {
282
        var that = this;
130✔
283

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

290
        this.ui.draggable.data('cms', this.options);
130✔
291

292
        this.ui.dragitem.on(Plugin.doubleClick, this._dblClickToEditHandler.bind(this));
130✔
293

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

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

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

306
            // find out new placeholder id
307
            var placeholder_id = that._getId(dragitem.closest('.cms-dragarea'));
5✔
308

309
            // if placeholder_id is empty, cancel
310
            if (!placeholder_id) {
5!
UNCOV
311
                return false;
×
312
            }
313

314
            var data = dragitem.data('cms');
5✔
315

316
            data.target = placeholder_id;
5✔
317
            data.parent = that._getId(dragitem.parent().closest('.cms-draggable'));
5✔
318
            data.move_a_copy = true;
5✔
319

320
            // expand the plugin we paste to
321
            CMS.settings.states.push(data.parent);
5✔
322
            Helpers.setSettings(CMS.settings);
5✔
323

324
            that.movePlugin(data);
5✔
325
        });
326

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

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

352
                    Plugin._removeHighlightPluginContent(this.options.plugin_id);
×
353
                });
354
            // attach event to the plugin menu
355
            this._setSettingsMenu(this.ui.submenu);
130✔
356

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

360
            // clickability of "Paste" menu item
361
            this._checkIfPasteAllowed();
130✔
362
        });
363
    },
364

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

369
        e.preventDefault();
×
UNCOV
370
        e.stopPropagation();
×
371

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

381
    _setPluginContentEvents: function _setPluginContentEvents() {
382
        const pluginDoubleClickEvent = this._getNamepacedEvent(Plugin.doubleClick);
130✔
383

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

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

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

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

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

449
                CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
450
            });
451
    },
452

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

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

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

482
        var bounds = this.options.plugin_restriction;
20✔
483

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

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

506
        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
5✔
507
        pasteItem.removeClass('cms-submenu-item-disabled');
5✔
508

509
        return true;
5✔
510
    },
511

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

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

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

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

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

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

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

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

585
        if (maybeChildren.length) {
×
UNCOV
586
            const lastChild = maybeChildren.last();
×
587

588
            const lastChildInstance = Plugin._getPluginById(this._getId(lastChild));
×
589

590
            return lastChildInstance.options.position + 1;
×
591
        }
592

UNCOV
593
        return this.options.position + 1;
×
594
    },
595

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

612
        this.modal = modal;
3✔
613

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

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

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

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

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

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

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

700
        $.ajax(request);
8✔
701
    },
702

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

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

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

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

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

765
        const clipboardDraggableClone = clipboardDraggable.clone(true, true);
5✔
766

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

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

794
        // set correct options
795
        const options = opts || this.options;
11✔
796

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

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

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

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

818
        if (Number(placeholder_id) === Number(options.placeholder_id)) {
10!
819
            Plugin._updatePluginPositions(options.placeholder_id);
10✔
820
        } else {
UNCOV
821
            data.placeholder_id = placeholder_id;
×
822

823
            Plugin._updatePluginPositions(placeholder_id);
×
UNCOV
824
            Plugin._updatePluginPositions(options.placeholder_id);
×
825
        }
826

827
        const position = this.options.position;
10✔
828

829
        data.target_position = position;
10✔
830

831
        showLoader();
10✔
832

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

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

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

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

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

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

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

906
        this.modal = modal;
2✔
907

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

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

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

939
        if (mustCleanup) {
2✔
940
            this.cleanup();
1✔
941
        }
942

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

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

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

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

982
        this.ui.dropdown = nav.siblings('.cms-submenu-dropdown-settings');
153✔
983
        var dropdown = this.ui.dropdown;
153✔
984

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

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

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

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

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

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

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

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

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

UNCOV
1094
            modal = new Modal({
×
1095
                minWidth: 400,
1096
                minHeight: 400
1097
            });
1098

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

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

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

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

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

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

UNCOV
1140
            plugins = nav.siblings('.cms-plugin-picker');
×
1141

1142
            that._setupQuickSearch(plugins);
×
1143
        });
1144

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

1157
                Plugin._hideSettingsMenu();
×
1158

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

1162
                if (selectionNeeded) {
×
UNCOV
1163
                    initModal();
×
1164

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

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

1188
                    that.addPlugin(pluginType, el.text(), parentId, showAddForm);
×
1189
                }
1190
            });
1191

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

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

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

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

UNCOV
1215
        let ref = plugins.find('.cms-quicksearch');
×
1216

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

1223
            if (item.length) {
×
UNCOV
1224
                const clone = item.closest('.cms-submenu-item').clone(true, true);
×
1225

1226
                ref.after(clone);
×
UNCOV
1227
                ref = clone;
×
1228
                count += 1;
×
1229
            }
1230
        });
1231

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

UNCOV
1240
        return plugins;
×
1241
    },
1242

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

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

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

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

1288
            resultElements = resultElements.filter(function(index) {
29✔
1289
                var item = $(this);
411✔
1290

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

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

1302
        return resultElements;
33✔
1303
    },
1304

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

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

1323
            that._filterPluginsList(pluginsPicker, input);
×
1324
        }, FILTER_DEBOUNCE_TIMER);
1325

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

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

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

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

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

1381
        var nav;
1382
        var that = this;
13✔
1383

1384
        if (e.data && e.data.nav) {
13!
UNCOV
1385
            nav = e.data.nav;
×
1386
        }
1387

1388
        // show loader and make sure scroll doesn't jump
1389
        showLoader();
13✔
1390

1391
        var items = '.cms-submenu-edit, .cms-submenu-item a';
13✔
1392
        var el = $(e.target).closest(items);
13✔
1393

1394
        Plugin._hideSettingsMenu(nav);
13✔
1395

1396
        // set switch for subnav entries
1397
        switch (el.attr('data-rel')) {
13!
1398

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

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

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

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

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

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

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

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

UNCOV
1514
        var dropdown = this.ui.dropdown;
×
1515
        var parents = nav.parentsUntil('.cms-dragarea').last();
×
UNCOV
1516
        var MIN_SCREEN_MARGIN = 10;
×
1517

1518
        nav.addClass('cms-btn-active');
×
1519
        parents.addClass('cms-z-index-9999');
×
1520

1521
        // set visible states
1522
        dropdown.show();
×
1523

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

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

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

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

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

1559
        // Simple case-insensitive substring matching (replaces fuzzyFilter)
1560
        var queryLower = query.toLowerCase();
4✔
1561

1562
        items.hide();
4✔
1563
        items.each(function() {
4✔
1564
            var item = $(this);
72✔
1565
            var text = item.text().toLowerCase();
72✔
1566

1567
            if (text.indexOf(queryLower) !== -1) {
72✔
1568
                item.show();
3✔
1569
            }
1570
        });
1571

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

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

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

1589
        mostRecentItems.hide();
4✔
1590
    },
1591

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

1606
        var settings = CMS.settings;
×
1607

1608
        settings.states = settings.states || [];
×
1609

1610
        if (!draggable || !draggable.length) {
×
1611
            return;
×
1612
        }
1613

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

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

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

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

1652
                    if (!item.hasClass('cms-dragitem-expanded')) {
×
1653
                        that._toggleCollapsable(item);
×
1654
                    }
1655
                });
1656
            }
1657
        }
1658

1659
        this._updatePlaceholderCollapseState();
×
1660

1661
        // make sure structurboard gets updated after expanding
1662
        $document.trigger('resize.sideframe');
×
1663

1664
        // save settings
1665
        Helpers.setSettings(settings);
×
1666
    },
1667

1668
    _updatePlaceholderCollapseState() {
1669
        if (this.options.type !== 'plugin' || !this.options.placeholder_id) {
×
1670
            return;
×
1671
        }
1672

1673
        const pluginsOfCurrentPlaceholder = CMS._plugins
×
1674
            .filter(([, o]) => o.placeholder_id === this.options.placeholder_id && o.type === 'plugin')
×
1675
            .map(([, o]) => o.plugin_id);
×
1676

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

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
1766
        el.addClass('cms-dragbar-title-expanded');
×
1767

UNCOV
1768
        var settings = CMS.settings;
×
1769

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

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

1786
        items.each(function() {
×
UNCOV
1787
            var item = $(this);
×
1788

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

UNCOV
1794
        el.removeClass('cms-dragbar-title-expanded');
×
1795

UNCOV
1796
        var settings = CMS.settings;
×
1797

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

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

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

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

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

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

1848
        var id = this.options.plugin_parent;
6✔
1849
        var data;
1850

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

1854
            if (!data) {
6✔
1855
                break;
1✔
1856
            }
1857

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

1865
        return breadcrumbs;
6✔
1866
    }
1867
});
1868

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

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

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

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

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

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

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

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

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

1948
    Plugin._updateClipboard();
6✔
1949

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

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

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

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

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

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

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

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

2034
        const options = allOptions[0];
×
2035

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

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

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

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

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

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

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

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

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

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

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

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

2098
    return false;
129✔
2099
};
2100

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2317
    CMS._plugins.forEach(([type, opts]) => {
4✔
2318
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2319
            const instance = CMS._instances.find(
8✔
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 CMS._instances.find(({ 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
NEW
2362
        CMS._instances.filter(({ 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