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

divio / django-cms / #29575

26 Mar 2025 05:41PM UTC coverage: 75.015% (-0.03%) from 75.044%
#29575

push

travis-ci

web-flow
Merge 709867a50 into c37fa0b7e

1076 of 1626 branches covered (66.17%)

2567 of 3422 relevant lines covered (75.01%)

26.24 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

222
        return itemContents;
137✔
223
    },
224

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

326
        setTimeout(() => {
130✔
327
            this.ui.dragitem
130✔
328
                .on('mouseenter', e => {
329
                    e.stopPropagation();
×
330
                    if (!$document.data('expandmode')) {
×
331
                        return;
×
332
                    }
333
                    if (this.ui.draggable.find('> .cms-dragitem > .cms-plugin-disabled').length) {
×
334
                        return;
×
335
                    }
336
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
337
                        return;
×
338
                    }
339
                    if (CMS.API.StructureBoard.dragging) {
×
340
                        return;
×
341
                    }
342
                    // eslint-disable-next-line no-magic-numbers
343
                    Plugin._highlightPluginContent(this.options.plugin_id, { successTimeout: 0, seeThrough: true });
×
344
                })
345
                .on('mouseleave', e => {
346
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
347
                        return;
×
348
                    }
349
                    e.stopPropagation();
×
350
                    // eslint-disable-next-line no-magic-numbers
351
                    Plugin._removeHighlightPluginContent(this.options.plugin_id);
×
352
                });
353
            // attach event to the plugin menu
354
            this._setSettingsMenu(this.ui.submenu);
130✔
355

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

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

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

368
        e.preventDefault();
×
369
        e.stopPropagation();
×
370

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

508
        return true;
5✔
509
    },
510

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

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

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

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

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

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

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

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

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

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

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

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

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

611
        this.modal = modal;
3✔
612

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

628
    /**
629
     * Used for copying _and_ pasting a plugin. If either of params
630
     * is present method assumes that it's "paste" and will make a call
631
     * to api to insert current plugin to specified `options.target_plugin_id`
632
     * or `options.target_placeholder_id`. Copying a plugin also first
633
     * clears the clipboard.
634
     *
635
     * @method copyPlugin
636
     * @param {Object} [opts=this.options]
637
     * @param {String} source_language
638
     * @returns {Boolean|void}
639
     */
640
    // eslint-disable-next-line complexity
641
    copyPlugin: function(opts, source_language) {
642
        // cancel request if already in progress
643
        if (CMS.API.locked) {
9✔
644
            return false;
1✔
645
        }
646
        CMS.API.locked = true;
8✔
647

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

828
        data.target_position = position;
10✔
829

830
        showLoader();
10✔
831

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

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

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

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

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

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

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

905
        this.modal = modal;
2✔
906

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1156
                Plugin._hideSettingsMenu();
×
1157

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

1161
                if (selectionNeeded) {
×
1162
                    initModal();
×
1163

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

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

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

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

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

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

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

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

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

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

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

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

1239
        return plugins;
×
1240
    },
1241

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

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

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

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

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

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

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

1301
        return resultElements;
33✔
1302
    },
1303

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

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

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

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

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

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

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

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

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

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

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

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

1393
        Plugin._hideSettingsMenu(nav);
13✔
1394

1395
        // set switch for subnav entries
1396
        switch (el.attr('data-rel')) {
13!
1397
            // eslint-disable-next-line no-case-declarations
1398
            case 'add':
1399
                const pluginType = el.attr('href').replace('#', '');
2✔
1400
                const showAddForm = el.data('addForm');
2✔
1401

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1557
        var itemsToFilter = items.toArray().map(function(el) {
4✔
1558
            var element = $(el);
72✔
1559

1560
            return {
72✔
1561
                value: element.text(),
1562
                element: element
1563
            };
1564
        });
1565

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

1568
        items.hide();
4✔
1569
        filteredItems.forEach(function(item) {
4✔
1570
            item.element.show();
3✔
1571
        });
1572

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

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

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

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

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

1607
        var settings = CMS.settings;
×
1608

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

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

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

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

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

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

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

1660
        this._updatePlaceholderCollapseState();
×
1661

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1768
        el.addClass('cms-dragbar-title-expanded');
×
1769

1770
        var settings = CMS.settings;
×
1771

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

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

1788
        items.each(function() {
×
1789
            var item = $(this);
×
1790

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

1796
        el.removeClass('cms-dragbar-title-expanded');
×
1797

1798
        var settings = CMS.settings;
×
1799

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

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

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

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

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

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

1850
        var id = this.options.plugin_parent;
6✔
1851
        var data;
1852

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

1856
            if (!data) {
6✔
1857
                break;
1✔
1858
            }
1859

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

1867
        return breadcrumbs;
6✔
1868
    }
1869
});
1870

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

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

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

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

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

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

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

1932
    // reset relativity
1933
    $('.cms-dragbar').css('position', '');
×
1934
};
1935

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

1950
    Plugin._updateClipboard();
6✔
1951

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

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

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

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

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

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

2029
        e.stopPropagation();
×
2030
        const pluginContainer = $(e.target).closest('.cms-plugin');
×
2031
        const allOptions = pluginContainer.data('cms');
×
2032

2033
        if (!allOptions || !allOptions.length) {
×
2034
            return;
×
2035
        }
2036

2037
        const options = allOptions[0];
×
2038

2039
        if (e.type === 'touchstart') {
×
2040
            CMS.API.Tooltip._forceTouchOnce();
×
2041
        }
2042
        var name = options.plugin_name;
×
2043
        var id = options.plugin_id;
×
2044
        var type = options.type;
×
2045

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

2052
        if (placeholder.length && placeholder.data('cms')) {
×
2053
            name = placeholder.data('cms').name + ': ' + name;
×
2054
        }
2055

2056
        CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
2057
    });
2058

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

2062
        if (placeholder.hasClass('cms-dragarea-static-expanded') && e.isDefaultPrevented()) {
×
2063
            return;
×
2064
        }
2065

2066
        placeholder.toggleClass('cms-dragarea-static-expanded');
×
2067
    });
2068

2069
    $window.on('blur.cms', () => {
6✔
2070
        $document.data('expandmode', false);
6✔
2071
    });
2072
};
2073

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

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

2089
    var pluginIds = currentData.map(function(pluginData) {
130✔
2090
        return pluginData.plugin_id;
131✔
2091
    });
2092

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

2101
    return false;
129✔
2102
};
2103

2104
/**
2105
 * Shows and immediately fades out a success notification (when
2106
 * plugin was successfully moved.
2107
 *
2108
 * @method _highlightPluginStructure
2109
 * @private
2110
 * @static
2111
 * @param {jQuery} el draggable element
2112
 */
2113
// eslint-disable-next-line no-magic-numbers
2114
Plugin._highlightPluginStructure = function _highlightPluginStructure(
1✔
2115
    el,
2116
    // eslint-disable-next-line no-magic-numbers
2117
    { successTimeout = 200, delay = 1500, seeThrough = false }
×
2118
) {
2119
    const tpl = $(`
×
2120
        <div class="cms-dragitem-success ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}">
×
2121
        </div>
2122
    `);
2123

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

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

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

2165
        if (width === 0 && height === 0) {
1!
2166
            return;
×
2167
        }
2168

2169
        if (isNaN(ml)) {
1!
2170
            ml = 0;
×
2171
        }
2172
        if (isNaN(mr)) {
1!
2173
            mr = 0;
×
2174
        }
2175
        if (isNaN(mt)) {
1!
2176
            mt = 0;
×
2177
        }
2178
        if (isNaN(mb)) {
1!
2179
            mb = 0;
×
2180
        }
2181

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

2190
    if (positions.length === 0) {
1!
2191
        return;
×
2192
    }
2193

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

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

2204
    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);
1✔
2205

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

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

2235
Plugin._clickToHighlightHandler = function _clickToHighlightHandler(e) {
1✔
2236
    if (CMS.settings.mode !== 'structure') {
×
2237
        return;
×
2238
    }
2239
    e.preventDefault();
×
2240
    e.stopPropagation();
×
2241
    // FIXME refactor into an object
2242
    CMS.API.StructureBoard._showAndHighlightPlugin(200, true); // eslint-disable-line no-magic-numbers
×
2243
};
2244

2245
Plugin._removeHighlightPluginContent = function(pluginId) {
1✔
2246
    $(`.cms-plugin-overlay-${pluginId}[data-success-timeout=0]`).remove();
×
2247
};
2248

2249
Plugin.aliasPluginDuplicatesMap = {};
1✔
2250
Plugin.staticPlaceholderDuplicatesMap = {};
1✔
2251

2252
// istanbul ignore next
2253
Plugin._initializeTree = function _initializeTree() {
2254
    const plugins = {};
2255

2256
    document.body.querySelectorAll(
2257
        'script[data-cms-plugin], ' +
2258
        'script[data-cms-placeholder], ' +
2259
        'script[data-cms-general]'
2260
    ).forEach(script => {
2261
        plugins[script.id] = JSON.parse(script.textContent || '{}');
2262
    });
2263

2264
    CMS._plugins = Object.entries(plugins);
2265
    CMS._instances = CMS._plugins.map(function(args) {
2266
        return new CMS.Plugin(args[0], args[1]);
2267
    });
2268

2269
    // return the cms plugin instances just created
2270
    return CMS._instances;
2271
};
2272

2273
Plugin._updateClipboard = function _updateClipboard() {
1✔
2274
    clipboardDraggable = $('.cms-draggable-from-clipboard:first');
7✔
2275
};
2276

2277
Plugin._updateUsageCount = function _updateUsageCount(pluginType) {
1✔
2278
    var currentValue = pluginUsageMap[pluginType] || 0;
2✔
2279

2280
    pluginUsageMap[pluginType] = currentValue + 1;
2✔
2281

2282
    if (Helpers._isStorageSupported) {
2!
2283
        localStorage.setItem('cms-plugin-usage', JSON.stringify(pluginUsageMap));
×
2284
    }
2285
};
2286

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

2292
Plugin._refreshPlugins = function refreshPlugins() {
1✔
2293
    Plugin.aliasPluginDuplicatesMap = {};
4✔
2294
    Plugin.staticPlaceholderDuplicatesMap = {};
4✔
2295

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

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

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

2321
    CMS._plugins.forEach(([type, opts]) => {
4✔
2322
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2323
            const instance = find(
8✔
2324
                CMS._instances,
2325
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
13✔
2326
            );
2327

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

2342
Plugin._getPluginById = function(id) {
1✔
2343
    return find(CMS._instances, ({ options }) => options.type === 'plugin' && Number(options.plugin_id) === Number(id));
20!
2344
};
2345

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

2351
    plugins.forEach((element, index) => {
10✔
2352
        const pluginId = CMS.API.StructureBoard.getId($(element));
20✔
2353
        const instance = Plugin._getPluginById(pluginId);
20✔
2354

2355
        if (!instance) {
20!
2356
            return;
20✔
2357
        }
2358

2359
        instance.options.position = index + 1;
×
2360
    });
2361
};
2362

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

2375
// shorthand for jQuery(document).ready();
2376
$(Plugin._initializeGlobalHandlers);
1✔
2377

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

© 2026 Coveralls, Inc