divio/django-cms

View on GitHub
cms/static/cms/js/modules/cms.plugins.js

Summary

Maintainability
F
1 wk
Test Coverage
/*
 * Copyright https://github.com/divio/django-cms
 */
import Modal from './cms.modal';
import StructureBoard from './cms.structureboard';
import $ from 'jquery';
import '../polyfills/array.prototype.findindex';
import nextUntil from './nextuntil';

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

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

var clipboardDraggable;
var path = window.location.pathname + window.location.search;

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

const isStructureReady = () =>
    CMS.config.settings.mode === 'structure' ||
    CMS.config.settings.legacy_mode ||
    CMS.API.StructureBoard._loadedStructure;
const isContentReady = () =>
    CMS.config.settings.mode !== 'structure' ||
    CMS.config.settings.legacy_mode ||
    CMS.API.StructureBoard._loadedContent;

/**
 * Class for handling Plugins / Placeholders or Generics.
 * Handles adding / moving / copying / pasting / menus etc
 * in structureboard.
 *
 * @class Plugin
 * @namespace CMS
 * @uses CMS.API.Helpers
 */
var Plugin = new Class({
    implement: [Helpers],

    options: {
        type: '', // bar, plugin or generic
        placeholder_id: null,
        plugin_type: '',
        plugin_id: null,
        plugin_parent: null,
        plugin_order: null,
        plugin_restriction: [],
        plugin_parent_restriction: [],
        urls: {
            add_plugin: '',
            edit_plugin: '',
            move_plugin: '',
            copy_plugin: '',
            delete_plugin: ''
        }
    },

    // these properties will be filled later
    modal: null,

    initialize: function initialize(container, options) {
        this.options = $.extend(true, {}, this.options, options);

        // create an unique for this component to use it internally
        this.uid = uid();

        this._setupUI(container);
        this._ensureData();

        if (this.options.type === 'plugin' && Plugin.aliasPluginDuplicatesMap[this.options.plugin_id]) {
            return;
        }
        if (this.options.type === 'placeholder' && Plugin.staticPlaceholderDuplicatesMap[this.options.placeholder_id]) {
            return;
        }

        // determine type of plugin
        switch (this.options.type) {
            case 'placeholder': // handler for placeholder bars
                Plugin.staticPlaceholderDuplicatesMap[this.options.placeholder_id] = true;
                this.ui.container.data('cms', this.options);
                this._setPlaceholder();
                if (isStructureReady()) {
                    this._collapsables();
                }
                break;
            case 'plugin': // handler for all plugins
                this.ui.container.data('cms').push(this.options);
                Plugin.aliasPluginDuplicatesMap[this.options.plugin_id] = true;
                this._setPlugin();
                if (isStructureReady()) {
                    this._collapsables();
                }
                break;
            default:
                // handler for static content
                this.ui.container.data('cms').push(this.options);
                this._setGeneric();
        }
    },

    _ensureData: function _ensureData() {
        // bind data element to the container (mutating!)
        if (!this.ui.container.data('cms')) {
            this.ui.container.data('cms', []);
        }
    },

    /**
     * Caches some jQuery references and sets up structure for
     * further initialisation.
     *
     * @method _setupUI
     * @private
     * @param {String} container `cms-plugin-${id}`
     */
    _setupUI: function setupUI(container) {
        var wrapper = $(`.${container}`);
        var contents;

        // have to check for cms-plugin, there can be a case when there are multiple
        // static placeholders or plugins rendered twice, there could be multiple wrappers on same page
        if (wrapper.length > 1 && container.match(/cms-plugin/)) {
            // so it's possible that multiple plugins (more often generics) are rendered
            // in different places. e.g. page menu in the header and in the footer
            // so first, we find all the template tags, then put them in a structure like this:
            // [[start, end], [start, end]...]
            //
            // in case of plugins it means that it's aliased plugin or a plugin in a duplicated
            // static placeholder (for whatever reason)
            var contentWrappers = wrapper.toArray().reduce((wrappers, elem, index) => {
                if (index === 0) {
                    wrappers[0].push(elem);
                    return wrappers;
                }

                var lastWrapper = wrappers[wrappers.length - 1];
                var lastItemInWrapper = lastWrapper[lastWrapper.length - 1];

                if ($(lastItemInWrapper).is('.cms-plugin-end')) {
                    wrappers.push([elem]);
                } else {
                    lastWrapper.push(elem);
                }

                return wrappers;
            }, [[]]);

            // then we map that structure into an array of jquery collections
            // from which we filter out empty ones
            contents = contentWrappers
                .map(items => {
                    var templateStart = $(items[0]);
                    var className = templateStart.attr('class').replace('cms-plugin-start', '');

                    var itemContents = $(nextUntil(templateStart[0], container));

                    $(items).filter('template').remove();

                    itemContents.each((index, el) => {
                        // Due to the way browsers interact with plugins and external code, the .data()
                        // method cannot be used on <object> (unless it's a Flash plugin), <applet> or <embed> elements,
                        // so we have to wrap them
                        if (includes(['OBJECT', 'EMBED', 'APPLET'], el.nodeName)) {
                            const element = $(el);

                            element.wrap('<cms-plugin class="cms-plugin-object-node"></cms-plugin>');
                            itemContents[index] = element.parent()[0];
                        }

                        // if it's a non-space top-level text node - wrap it in `cms-plugin`
                        if (el.nodeType === Node.TEXT_NODE && !el.textContent.match(/^\s*$/)) {
                            const element = $(el);

                            element.wrap('<cms-plugin class="cms-plugin-text-node"></cms-plugin>');
                            itemContents[index] = element.parent()[0];
                        }
                    });

                    // otherwise we don't really need text nodes or comment nodes or empty text nodes
                    itemContents = itemContents.filter(function() {
                        return this.nodeType !== Node.TEXT_NODE && this.nodeType !== Node.COMMENT_NODE;
                    });

                    itemContents.addClass(`cms-plugin ${className}`);

                    return itemContents;
                })
                .filter(v => v.length);

            if (contents.length) {
                // and then reduce it to one big collection
                contents = contents.reduce((collection, items) => collection.add(items), $());
            }
        } else {
            contents = wrapper;
        }

        // in clipboard can be non-existent
        if (!contents.length) {
            contents = $('<div></div>');
        }

        this.ui = this.ui || {};
        this.ui.container = contents;
    },

    /**
     * Sets up behaviours and ui for placeholder.
     *
     * @method _setPlaceholder
     * @private
     */
    _setPlaceholder: function() {
        var that = this;

        this.ui.dragbar = $('.cms-dragbar-' + this.options.placeholder_id);
        this.ui.draggables = this.ui.dragbar.closest('.cms-dragarea').find('> .cms-draggables');
        this.ui.submenu = this.ui.dragbar.find('.cms-submenu-settings');
        var title = this.ui.dragbar.find('.cms-dragbar-title');
        var togglerLinks = this.ui.dragbar.find('.cms-dragbar-toggler a');
        var expanded = 'cms-dragbar-title-expanded';

        // register the subnav on the placeholder
        this._setSettingsMenu(this.ui.submenu);
        this._setAddPluginModal(this.ui.dragbar.find('.cms-submenu-add'));

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

        // enable expanding/collapsing globally within the placeholder
        togglerLinks.off(Plugin.click).on(Plugin.click, function(e) {
            e.preventDefault();
            if (title.hasClass(expanded)) {
                that._collapseAll(title);
            } else {
                that._expandAll(title);
            }
        });

        if ($.inArray(this.options.placeholder_id, CMS.settings.dragbars) !== -1) {
            title.addClass(expanded);
        }

        this._checkIfPasteAllowed();
    },

    /**
     * Sets up behaviours and ui for plugin.
     *
     * @method _setPlugin
     * @private
     */
    _setPlugin: function() {
        if (isStructureReady()) {
            this._setPluginStructureEvents();
        }
        if (isContentReady()) {
            this._setPluginContentEvents();
        }
    },

    _setPluginStructureEvents: function _setPluginStructureEvents() {
        var that = this;

        // filling up ui object
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
        this.ui.dragitem = this.ui.draggable.find('> .cms-dragitem');
        this.ui.draggables = this.ui.draggable.find('> .cms-draggables');
        this.ui.submenu = this.ui.dragitem.find('.cms-submenu');

        this.ui.draggable.data('cms', this.options);

        this.ui.dragitem.on(Plugin.doubleClick, this._dblClickToEditHandler.bind(this));

        // adds listener for all plugin updates
        this.ui.draggable.off('cms-plugins-update').on('cms-plugins-update', function(e, eventData) {
            e.stopPropagation();
            that.movePlugin(null, eventData);
        });

        // adds listener for copy/paste updates
        this.ui.draggable.off('cms-paste-plugin-update').on('cms-paste-plugin-update', function(e, eventData) {
            e.stopPropagation();

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

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

            // if placeholder_id is empty, cancel
            if (!placeholder_id) {
                return false;
            }

            var data = dragitem.data('cms');

            data.target = placeholder_id;
            data.parent = that._getId(dragitem.parent().closest('.cms-draggable'));
            data.move_a_copy = true;

            // expand the plugin we paste to
            CMS.settings.states.push(data.parent);
            Helpers.setSettings(CMS.settings);

            that.movePlugin(data);
        });

        setTimeout(() => {
            this.ui.dragitem
                .on('mouseenter', e => {
                    e.stopPropagation();
                    if (!$document.data('expandmode')) {
                        return;
                    }
                    if (this.ui.draggable.find('> .cms-dragitem > .cms-plugin-disabled').length) {
                        return;
                    }
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
                        return;
                    }
                    if (CMS.API.StructureBoard.dragging) {
                        return;
                    }
                    // eslint-disable-next-line no-magic-numbers
                    Plugin._highlightPluginContent(this.options.plugin_id, { successTimeout: 0, seeThrough: true });
                })
                .on('mouseleave', e => {
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
                        return;
                    }
                    e.stopPropagation();
                    // eslint-disable-next-line no-magic-numbers
                    Plugin._removeHighlightPluginContent(this.options.plugin_id);
                });
            // attach event to the plugin menu
            this._setSettingsMenu(this.ui.submenu);

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

            // clickability of "Paste" menu item
            this._checkIfPasteAllowed();
        });
    },

    _dblClickToEditHandler: function _dblClickToEditHandler(e) {
        var that = this;

        e.preventDefault();
        e.stopPropagation();

        that.editPlugin(
            Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
            that.options.plugin_name,
            that._getPluginBreadcrumbs()
        );
    },

    _setPluginContentEvents: function _setPluginContentEvents() {
        const pluginDoubleClickEvent = this._getNamepacedEvent(Plugin.doubleClick);

        this.ui.container
            .off('mouseover.cms.plugins')
            .on('mouseover.cms.plugins', e => {
                if (!$document.data('expandmode')) {
                    return;
                }
                if (CMS.settings.mode !== 'structure') {
                    return;
                }
                e.stopPropagation();
                $('.cms-dragitem-success').remove();
                $('.cms-draggable-success').removeClass('cms-draggable-success');
                CMS.API.StructureBoard._showAndHighlightPlugin(0, true); // eslint-disable-line no-magic-numbers
            })
            .off('mouseout.cms.plugins')
            .on('mouseout.cms.plugins', e => {
                if (CMS.settings.mode !== 'structure') {
                    return;
                }
                e.stopPropagation();
                if (this.ui.draggable && this.ui.draggable.length) {
                    this.ui.draggable.find('.cms-dragitem-success').remove();
                    this.ui.draggable.removeClass('cms-draggable-success');
                }
                // Plugin._removeHighlightPluginContent(this.options.plugin_id);
            });

        if (!Plugin._isContainingMultiplePlugins(this.ui.container)) {
            $document
                .off(pluginDoubleClickEvent, `.cms-plugin-${this.options.plugin_id}`)
                .on(
                    pluginDoubleClickEvent,
                    `.cms-plugin-${this.options.plugin_id}`,
                    this._dblClickToEditHandler.bind(this)
                );
        }
    },

    /**
     * Sets up behaviours and ui for generics.
     * Generics do not show up in structure board.
     *
     * @method _setGeneric
     * @private
     */
    _setGeneric: function() {
        var that = this;

        // adds double click to edit
        this.ui.container.off(Plugin.doubleClick).on(Plugin.doubleClick, function(e) {
            e.preventDefault();
            e.stopPropagation();
            that.editPlugin(Helpers.updateUrlWithPath(that.options.urls.edit_plugin), that.options.plugin_name, []);
        });

        // adds edit tooltip
        this.ui.container
            .off(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart)
            .on(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart, function(e) {
                if (e.type !== 'touchstart') {
                    e.stopPropagation();
                }
                var name = that.options.plugin_name;
                var id = that.options.plugin_id;

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

    /**
     * Checks if paste is allowed into current plugin/placeholder based
     * on restrictions we have. Also determines which tooltip to show.
     *
     * WARNING: this relies on clipboard plugins always being instantiated
     * first, so they have data('cms') by the time this method is called.
     *
     * @method _checkIfPasteAllowed
     * @private
     * @returns {Boolean}
     */
    _checkIfPasteAllowed: function _checkIfPasteAllowed() {
        var pasteButton = this.ui.dropdown.find('[data-rel=paste]');
        var pasteItem = pasteButton.parent();

        if (!clipboardDraggable.length) {
            pasteItem.addClass('cms-submenu-item-disabled');
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
            pasteItem.find('.cms-submenu-item-paste-tooltip-empty').css('display', 'block');
            return false;
        }

        if (this.ui.draggable && this.ui.draggable.hasClass('cms-draggable-disabled')) {
            pasteItem.addClass('cms-submenu-item-disabled');
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
            pasteItem.find('.cms-submenu-item-paste-tooltip-disabled').css('display', 'block');
            return false;
        }

        var bounds = this.options.plugin_restriction;

        if (clipboardDraggable.data('cms')) {
            var clipboardPluginData = clipboardDraggable.data('cms');
            var type = clipboardPluginData.plugin_type;
            var parent_bounds = $.grep(clipboardPluginData.plugin_parent_restriction, function(restriction) {
                // special case when PlaceholderPlugin has a parent restriction named "0"
                return restriction !== '0';
            });
            var currentPluginType = this.options.plugin_type;

            if (
                (bounds.length && $.inArray(type, bounds) === -1) ||
                (parent_bounds.length && $.inArray(currentPluginType, parent_bounds) === -1)
            ) {
                pasteItem.addClass('cms-submenu-item-disabled');
                pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
                pasteItem.find('.cms-submenu-item-paste-tooltip-restricted').css('display', 'block');
                return false;
            }
        } else {
            return false;
        }

        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
        pasteItem.removeClass('cms-submenu-item-disabled');

        return true;
    },

    /**
     * Calls api to create a plugin and then proceeds to edit it.
     *
     * @method addPlugin
     * @param {String} type type of the plugin, e.g "Bootstrap3ColumnCMSPlugin"
     * @param {String} name name of the plugin, e.g. "Column"
     * @param {String} parent id of a parent plugin
     */
    addPlugin: function(type, name, parent) {
        var params = {
            placeholder_id: this.options.placeholder_id,
            plugin_type: type,
            cms_path: path,
            plugin_language: CMS.config.request.language
        };

        if (parent) {
            params.plugin_parent = parent;
        }
        var url = this.options.urls.add_plugin + '?' + $.param(params);
        var modal = new Modal({
            onClose: this.options.onClose || false,
            redirectOnClose: this.options.redirectOnClose || false
        });

        modal.open({
            url: url,
            title: name
        });

        this.modal = modal;

        Helpers.removeEventListener('modal-closed.add-plugin');
        Helpers.addEventListener('modal-closed.add-plugin', (e, { instance }) => {
            if (instance !== modal) {
                return;
            }
            Plugin._removeAddPluginPlaceholder();
        });
    },

    /**
     * Opens the modal for editing a plugin.
     *
     * @method editPlugin
     * @param {String} url editing url
     * @param {String} name Name of the plugin, e.g. "Column"
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
     *     each item is `{ title: 'string': url: 'string' }`
     */
    editPlugin: function(url, name, breadcrumb) {
        // trigger modal window
        var modal = new Modal({
            onClose: this.options.onClose || false,
            redirectOnClose: this.options.redirectOnClose || false
        });

        this.modal = modal;

        Helpers.removeEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin');
        Helpers.addEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin', (e, { instance }) => {
            if (instance === modal) {
                // cannot be cached
                Plugin._removeAddPluginPlaceholder();
            }
        });
        modal.open({
            url: url,
            title: name,
            breadcrumbs: breadcrumb
        });
    },

    /**
     * Used for copying _and_ pasting a plugin. If either of params
     * is present method assumes that it's "paste" and will make a call
     * to api to insert current plugin to specified `options.target_plugin_id`
     * or `options.target_placeholder_id`. Copying a plugin also first
     * clears the clipboard.
     *
     * @method copyPlugin
     * @param {Object} [opts=this.options]
     * @param {String} source_language
     * @returns {Boolean|void}
     */
    // eslint-disable-next-line complexity
    copyPlugin: function(opts, source_language) {
        // cancel request if already in progress
        if (CMS.API.locked) {
            return false;
        }
        CMS.API.locked = true;

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

        if (sourceLanguage) {
            copyingFromLanguage = true;
            options.target = options.placeholder_id;
            options.plugin_id = '';
            options.parent = '';
        } else {
            sourceLanguage = CMS.config.request.language;
        }

        var data = {
            source_placeholder_id: options.placeholder_id,
            source_plugin_id: options.plugin_id || '',
            source_language: sourceLanguage,
            target_plugin_id: options.parent || '',
            target_placeholder_id: options.target || CMS.config.clipboard.id,
            csrfmiddlewaretoken: CMS.config.csrf,
            target_language: CMS.config.request.language
        };
        var request = {
            type: 'POST',
            url: Helpers.updateUrlWithPath(options.urls.copy_plugin),
            data: data,
            success: function(response) {
                CMS.API.Messages.open({
                    message: CMS.config.lang.success
                });
                if (copyingFromLanguage) {
                    CMS.API.StructureBoard.invalidateState('PASTE', $.extend({}, data, response));
                } else {
                    CMS.API.StructureBoard.invalidateState('COPY', response);
                }
                CMS.API.locked = false;
                hideLoader();
            },
            error: function(jqXHR) {
                CMS.API.locked = false;
                var msg = CMS.config.lang.error;

                // trigger error
                CMS.API.Messages.open({
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
                    error: true
                });
            }
        };

        $.ajax(request);
    },

    /**
     * Essentially clears clipboard and moves plugin to a clipboard
     * placholder through `movePlugin`.
     *
     * @method cutPlugin
     * @returns {Boolean|void}
     */
    cutPlugin: function() {
        // if cut is once triggered, prevent additional actions
        if (CMS.API.locked) {
            return false;
        }
        CMS.API.locked = true;

        var that = this;
        var data = {
            placeholder_id: CMS.config.clipboard.id,
            plugin_id: this.options.plugin_id,
            plugin_parent: '',
            plugin_order: [this.options.plugin_id],
            target_language: CMS.config.request.language,
            csrfmiddlewaretoken: CMS.config.csrf
        };

        // move plugin
        $.ajax({
            type: 'POST',
            url: Helpers.updateUrlWithPath(that.options.urls.move_plugin),
            data: data,
            success: function(response) {
                CMS.API.locked = false;
                CMS.API.Messages.open({
                    message: CMS.config.lang.success
                });
                CMS.API.StructureBoard.invalidateState('CUT', $.extend({}, data, response));
                hideLoader();
            },
            error: function(jqXHR) {
                CMS.API.locked = false;
                var msg = CMS.config.lang.error;

                // trigger error
                CMS.API.Messages.open({
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
                    error: true
                });
                hideLoader();
            }
        });
    },

    /**
     * Method is called when you click on the paste button on the plugin.
     * Uses existing solution of `copyPlugin(options)`
     *
     * @method pastePlugin
     */
    pastePlugin: function() {
        var id = this._getId(clipboardDraggable);
        var eventData = {
            id: id
        };

        const clipboardDraggableClone = clipboardDraggable.clone(true, true);

        clipboardDraggableClone.appendTo(this.ui.draggables);
        if (this.options.plugin_id) {
            StructureBoard.actualizePluginCollapseStatus(this.options.plugin_id);
        }
        this.ui.draggables.trigger('cms-structure-update', [eventData]);
        clipboardDraggableClone.trigger('cms-paste-plugin-update', [eventData]);
    },

    /**
     * Moves plugin by querying the API and then updates some UI parts
     * to reflect that the page has changed.
     *
     * @method movePlugin
     * @param {Object} [opts=this.options]
     * @param {String} [opts.placeholder_id]
     * @param {String} [opts.plugin_id]
     * @param {String} [opts.plugin_parent]
     * @param {Boolean} [opts.move_a_copy]
     * @returns {Boolean|void}
     */
    movePlugin: function(opts) {
        // cancel request if already in progress
        if (CMS.API.locked) {
            return false;
        }
        CMS.API.locked = true;

        // set correct options
        var options = opts || this.options;

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

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

        var plugin_parent = this._getId(dragitem.parent().closest('.cms-draggable'));
        var plugin_order = this._getIds(dragitem.siblings('.cms-draggable').andSelf());

        if (options.move_a_copy) {
            plugin_order = plugin_order.map(function(pluginId) {
                var id = pluginId;

                // correct way would be to check if it's actually a
                // pasted plugin and only then replace the id with copy token
                // otherwise if we would copy from the same placeholder we would get
                // two copy tokens instead of original and a copy.
                // it's ok so far, as long as we copy only from clipboard
                if (id === options.plugin_id) {
                    id = '__COPY__';
                }
                return id;
            });
        }

        // cancel here if we have no placeholder id
        if (placeholder_id === false) {
            return false;
        }

        // gather the data for ajax request
        var data = {
            placeholder_id: placeholder_id,
            plugin_id: options.plugin_id,
            plugin_parent: plugin_parent || '',
            target_language: CMS.config.request.language,
            plugin_order: plugin_order,
            csrfmiddlewaretoken: CMS.config.csrf,
            move_a_copy: options.move_a_copy
        };

        showLoader();

        $.ajax({
            type: 'POST',
            url: Helpers.updateUrlWithPath(options.urls.move_plugin),
            data: data,
            success: function(response) {
                CMS.API.StructureBoard.invalidateState(
                    data.move_a_copy ? 'PASTE' : 'MOVE',
                    $.extend({}, data, response)
                );

                // enable actions again
                CMS.API.locked = false;
                hideLoader();
            },
            error: function(jqXHR) {
                CMS.API.locked = false;
                var msg = CMS.config.lang.error;

                // trigger error
                CMS.API.Messages.open({
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
                    error: true
                });
                hideLoader();
            }
        });
    },

    /**
     * Changes the settings attributes on an initialised plugin.
     *
     * @method _setSettings
     * @param {Object} oldSettings current settings
     * @param {Object} newSettings new settings to be applied
     * @private
     */
    _setSettings: function _setSettings(oldSettings, newSettings) {
        var settings = $.extend(true, {}, oldSettings, newSettings);
        var plugin = $('.cms-plugin-' + settings.plugin_id);
        var draggable = $('.cms-draggable-' + settings.plugin_id);

        // set new setting on instance and plugin data
        this.options = settings;
        if (plugin.length) {
            var index = plugin.data('cms').findIndex(function(pluginData) {
                return pluginData.plugin_id === settings.plugin_id;
            });

            plugin.each(function() {
                $(this).data('cms')[index] = settings;
            });
        }
        if (draggable.length) {
            draggable.data('cms', settings);
        }
    },

    /**
     * Opens a modal to delete a plugin.
     *
     * @method deletePlugin
     * @param {String} url admin url for deleting a page
     * @param {String} name plugin name, e.g. "Column"
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
     *     each item is `{ title: 'string': url: 'string' }`
     */
    deletePlugin: function(url, name, breadcrumb) {
        // trigger modal window
        var modal = new Modal({
            onClose: this.options.onClose || false,
            redirectOnClose: this.options.redirectOnClose || false
        });

        this.modal = modal;

        Helpers.removeEventListener('modal-loaded.delete-plugin');
        Helpers.addEventListener('modal-loaded.delete-plugin', (e, { instance }) => {
            if (instance === modal) {
                Plugin._removeAddPluginPlaceholder();
            }
        });
        modal.open({
            url: url,
            title: name,
            breadcrumbs: breadcrumb
        });
    },

    /**
     * Destroys the current plugin instance removing only the DOM listeners
     *
     * @method destroy
     * @param {Object}  options - destroy config options
     * @param {Boolean} options.mustCleanup - if true it will remove also the plugin UI components from the DOM
     * @returns {void}
     */
    destroy(options = {}) {
        const mustCleanup = options.mustCleanup || false;

        // close the plugin modal if it was open
        if (this.modal) {
            this.modal.close();
            // unsubscribe to all the modal events
            this.modal.off();
        }

        if (mustCleanup) {
            this.cleanup();
        }

        // remove event bound to global elements like document or window
        $document.off(`.${this.uid}`);
        $window.off(`.${this.uid}`);
    },

    /**
     * Remove the plugin specific ui elements from the DOM
     *
     * @method cleanup
     * @returns {void}
     */
    cleanup() {
        // remove all the plugin UI DOM elements
        // notice that $.remove will remove also all the ui specific events
        // previously attached to them
        Object.keys(this.ui).forEach(el => this.ui[el].remove());
    },

    /**
     * Called after plugin is added through ajax.
     *
     * @method editPluginPostAjax
     * @param {Object} toolbar CMS.API.Toolbar instance (not used)
     * @param {Object} response response from server
     */
    editPluginPostAjax: function(toolbar, response) {
        this.editPlugin(Helpers.updateUrlWithPath(response.url), this.options.plugin_name, response.breadcrumb);
    },

    /**
     * _setSettingsMenu sets up event handlers for settings menu.
     *
     * @method _setSettingsMenu
     * @private
     * @param {jQuery} nav
     */
    _setSettingsMenu: function _setSettingsMenu(nav) {
        var that = this;

        this.ui.dropdown = nav.siblings('.cms-submenu-dropdown-settings');
        var dropdown = this.ui.dropdown;

        nav
            .off(Plugin.pointerUp)
            .on(Plugin.pointerUp, function(e) {
                e.preventDefault();
                e.stopPropagation();
                var trigger = $(this);

                if (trigger.hasClass('cms-btn-active')) {
                    Plugin._hideSettingsMenu(trigger);
                } else {
                    Plugin._hideSettingsMenu();
                    that._showSettingsMenu(trigger);
                }
            })
            .off(Plugin.touchStart)
            .on(Plugin.touchStart, function(e) {
                // required on some touch devices so
                // ui touch punch is not triggering mousemove
                // which in turn results in pep triggering pointercancel
                e.stopPropagation();
            });

        dropdown
            .off(Plugin.mouseEvents)
            .on(Plugin.mouseEvents, function(e) {
                e.stopPropagation();
            })
            .off(Plugin.touchStart)
            .on(Plugin.touchStart, function(e) {
                // required for scrolling on mobile
                e.stopPropagation();
            });

        that._setupActions(nav);
        // prevent propagation
        nav
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '))
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
                e.stopPropagation();
            });

        nav
            .siblings('.cms-quicksearch, .cms-submenu-dropdown-settings')
            .off([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '))
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
                e.stopPropagation();
            });
    },

    /**
     * Simplistic implementation, only scrolls down, only works in structuremode
     * and highly depends on the styles of the structureboard to work correctly
     *
     * @method _scrollToElement
     * @private
     * @param {jQuery} el element to scroll to
     * @param {Object} [opts]
     * @param {Number} [opts.duration=200] time to scroll
     * @param {Number} [opts.offset=50] distance in px to the bottom of the screen
     */
    _scrollToElement: function _scrollToElement(el, opts) {
        var DEFAULT_DURATION = 200;
        var DEFAULT_OFFSET = 50;
        var duration = opts && opts.duration !== undefined ? opts.duration : DEFAULT_DURATION;
        var offset = opts && opts.offset !== undefined ? opts.offset : DEFAULT_OFFSET;
        var scrollable = el.offsetParent();
        var scrollHeight = $window.height();
        var scrollTop = scrollable.scrollTop();
        var elPosition = el.position().top;
        var elHeight = el.height();
        var isInViewport = elPosition + elHeight + offset <= scrollHeight;

        if (!isInViewport) {
            scrollable.animate(
                {
                    scrollTop: elPosition + offset + elHeight + scrollTop - scrollHeight
                },
                duration
            );
        }
    },

    /**
     * Opens a modal with traversable plugins list, adds a placeholder to where
     * the plugin will be added.
     *
     * @method _setAddPluginModal
     * @private
     * @param {jQuery} nav modal trigger element
     * @returns {Boolean|void}
     */
    _setAddPluginModal: function _setAddPluginModal(nav) {
        if (nav.hasClass('cms-btn-disabled')) {
            return false;
        }
        var that = this;
        var modal;
        var isTouching;
        var plugins;

        var initModal = once(function initModal() {
            var placeholder = $(
                '<div class="cms-add-plugin-placeholder">' + CMS.config.lang.addPluginPlaceholder + '</div>'
            );
            var dragItem = nav.closest('.cms-dragitem');
            var isPlaceholder = !dragItem.length;
            var childrenList;

            modal = new Modal({
                minWidth: 400,
                minHeight: 400
            });

            if (isPlaceholder) {
                childrenList = nav.closest('.cms-dragarea').find('> .cms-draggables');
            } else {
                childrenList = nav.closest('.cms-draggable').find('> .cms-draggables');
            }

            Helpers.addEventListener('modal-loaded', (e, { instance }) => {
                if (instance !== modal) {
                    return;
                }

                that._setupKeyboardTraversing();
                if (childrenList.hasClass('cms-hidden') && !isPlaceholder) {
                    that._toggleCollapsable(dragItem);
                }
                Plugin._removeAddPluginPlaceholder();
                placeholder.appendTo(childrenList);
                that._scrollToElement(placeholder);
            });

            Helpers.addEventListener('modal-closed', (e, { instance }) => {
                if (instance !== modal) {
                    return;
                }
                Plugin._removeAddPluginPlaceholder();
            });

            Helpers.addEventListener('modal-shown', (e, { instance }) => {
                if (modal !== instance) {
                    return;
                }
                var dropdown = $('.cms-modal-markup .cms-plugin-picker');

                if (!isTouching) {
                    // only focus the field if using mouse
                    // otherwise keyboard pops up
                    dropdown.find('input').trigger('focus');
                }
                isTouching = false;
            });

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

            that._setupQuickSearch(plugins);
        });

        nav
            .on(Plugin.touchStart, function(e) {
                isTouching = true;
                // required on some touch devices so
                // ui touch punch is not triggering mousemove
                // which in turn results in pep triggering pointercancel
                e.stopPropagation();
            })
            .on(Plugin.pointerUp, function(e) {
                e.preventDefault();
                e.stopPropagation();

                Plugin._hideSettingsMenu();

                initModal();

                // since we don't know exact plugin parent (because dragndrop)
                // we need to know the parent id by the time we open "add plugin" dialog
                var pluginsCopy = that._updateWithMostUsedPlugins(
                    plugins
                        .clone(true, true)
                        .data('parentId', that._getId(nav.closest('.cms-draggable')))
                        .append(that._getPossibleChildClasses())
                );

                modal.open({
                    title: that.options.addPluginHelpTitle,
                    html: pluginsCopy,
                    width: 530,
                    height: 400
                });
            });

        // prevent propagation
        nav.on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
            e.stopPropagation();
        });

        nav
            .siblings('.cms-quicksearch, .cms-submenu-dropdown')
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
                e.stopPropagation();
            });
    },

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

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

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

        mostUsedPlugins.forEach(([name]) => {
            if (count === MAX_MOST_USED_PLUGINS) {
                return;
            }
            const item = items.find(`[href=${name}]`);

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

                ref.after(clone);
                ref = clone;
                count += 1;
            }
        });

        if (count) {
            plugins.find('.cms-quicksearch').after(
                $(`<div class="cms-submenu-item cms-submenu-item-title" data-cms-most-used>
                    <span>${CMS.config.lang.mostUsed}</span>
                </div>`)
            );
        }

        return plugins;
    },

    /**
     * Returns a specific plugin namespaced event postfixing the plugin uid to it
     * in order to properly manage it via jQuery $.on and $.off
     *
     * @method _getNamepacedEvent
     * @private
     * @param {String} base - plugin event type
     * @param {String} additionalNS - additional namespace (like '.traverse' for example)
     * @returns {String} a specific plugin event
     *
     * @example
     *
     * plugin._getNamepacedEvent(Plugin.click); // 'click.cms.plugin.42'
     * plugin._getNamepacedEvent(Plugin.keyDown, '.traverse'); // 'keydown.cms.plugin.traverse.42'
     */
    _getNamepacedEvent(base, additionalNS = '') {
        return `${base}${additionalNS ? '.'.concat(additionalNS) : ''}.${this.uid}`;
    },

    /**
     * Returns available plugin/placeholder child classes markup
     * for "Add plugin" modal
     *
     * @method _getPossibleChildClasses
     * @private
     * @returns {jQuery} "add plugin" menu
     */
    _getPossibleChildClasses: function _getPossibleChildClasses() {
        var that = this;
        var childRestrictions = this.options.plugin_restriction;
        // have to check the placeholder every time, since plugin could've been
        // moved as part of another plugin
        var placeholderId = that._getId(that.ui.submenu.closest('.cms-dragarea'));
        var resultElements = $($('#cms-plugin-child-classes-' + placeholderId).html());

        if (childRestrictions && childRestrictions.length) {
            resultElements = resultElements.filter(function() {
                var item = $(this);

                return (
                    item.hasClass('cms-submenu-item-title') ||
                    childRestrictions.indexOf(item.find('a').attr('href')) !== -1
                );
            });

            resultElements = resultElements.filter(function(index) {
                var item = $(this);

                return (
                    !item.hasClass('cms-submenu-item-title') ||
                    (item.hasClass('cms-submenu-item-title') &&
                        (!resultElements.eq(index + 1).hasClass('cms-submenu-item-title') &&
                            resultElements.eq(index + 1).length))
                );
            });
        }

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

        return resultElements;
    },

    /**
     * Sets up event handlers for quicksearching in the plugin picker.
     *
     * @method _setupQuickSearch
     * @private
     * @param {jQuery} plugins plugins picker element
     */
    _setupQuickSearch: function _setupQuickSearch(plugins) {
        var that = this;
        var FILTER_DEBOUNCE_TIMER = 100;
        var FILTER_PICK_DEBOUNCE_TIMER = 110;

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

            that._filterPluginsList(pluginsPicker, input);
        }, FILTER_DEBOUNCE_TIMER);

        plugins.find('> .cms-quicksearch').find('input').on(Plugin.keyUp, handler).on(
            Plugin.keyUp,
            debounce(function(e) {
                var input;
                var pluginsPicker;

                if (e.keyCode === KEYS.ENTER) {
                    input = $(this);
                    pluginsPicker = input.closest('.cms-plugin-picker');
                    pluginsPicker
                        .find('.cms-submenu-item')
                        .not('.cms-submenu-item-title')
                        .filter(':visible')
                        .first()
                        .find('> a')
                        .focus()
                        .trigger('click');
                }
            }, FILTER_PICK_DEBOUNCE_TIMER)
        );
    },

    /**
     * Sets up click handlers for various plugin/placeholder items.
     * Items can be anywhere in the plugin dragitem, not only in dropdown.
     *
     * @method _setupActions
     * @private
     * @param {jQuery} nav dropdown trigger with the items
     */
    _setupActions: function _setupActions(nav) {
        var items = '.cms-submenu-edit, .cms-submenu-item a';
        var parent = nav.parent();

        parent.find('.cms-submenu-edit').off(Plugin.touchStart).on(Plugin.touchStart, function(e) {
            // required on some touch devices so
            // ui touch punch is not triggering mousemove
            // which in turn results in pep triggering pointercancel
            e.stopPropagation();
        });
        parent.find(items).off(Plugin.click).on(Plugin.click, nav, e => this._delegate(e));
    },

    /**
     * Handler for the "action" items
     *
     * @method _delegate
     * @param {$.Event} e event
     * @private
     */
    // eslint-disable-next-line complexity
    _delegate: function _delegate(e) {
        e.preventDefault();
        e.stopPropagation();

        var nav;
        var that = this;

        if (e.data && e.data.nav) {
            nav = e.data.nav;
        }

        // show loader and make sure scroll doesn't jump
        showLoader();

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

        Plugin._hideSettingsMenu(nav);

        // set switch for subnav entries
        switch (el.attr('data-rel')) {
            // eslint-disable-next-line no-case-declarations
            case 'add':
                const pluginType = el.attr('href').replace('#', '');

                Plugin._updateUsageCount(pluginType);
                that.addPlugin(pluginType, el.text(), el.closest('.cms-plugin-picker').data('parentId'));
                break;
            case 'ajax_add':
                CMS.API.Toolbar.openAjax({
                    url: el.attr('href'),
                    post: JSON.stringify(el.data('post')),
                    text: el.data('text'),
                    callback: $.proxy(that.editPluginPostAjax, that),
                    onSuccess: el.data('on-success')
                });
                break;
            case 'edit':
                that.editPlugin(
                    Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
                    that.options.plugin_name,
                    that._getPluginBreadcrumbs()
                );
                break;
            case 'copy-lang':
                that.copyPlugin(that.options, el.attr('data-language'));
                break;
            case 'copy':
                if (el.parent().hasClass('cms-submenu-item-disabled')) {
                    hideLoader();
                } else {
                    that.copyPlugin();
                }
                break;
            case 'cut':
                that.cutPlugin();
                break;
            case 'paste':
                hideLoader();
                if (!el.parent().hasClass('cms-submenu-item-disabled')) {
                    that.pastePlugin();
                }
                break;
            case 'delete':
                that.deletePlugin(
                    Helpers.updateUrlWithPath(that.options.urls.delete_plugin),
                    that.options.plugin_name,
                    that._getPluginBreadcrumbs()
                );
                break;
            case 'highlight':
                hideLoader();
                // eslint-disable-next-line no-magic-numbers
                window.location.hash = `cms-plugin-${this.options.plugin_id}`;
                Plugin._highlightPluginContent(this.options.plugin_id, { seeThrough: true });
                e.stopImmediatePropagation();
                break;
            default:
                hideLoader();
                CMS.API.Toolbar._delegate(el);
        }
    },

    /**
     * Sets up keyboard traversing of plugin picker.
     *
     * @method _setupKeyboardTraversing
     * @private
     */
    _setupKeyboardTraversing: function _setupKeyboardTraversing() {
        var dropdown = $('.cms-modal-markup .cms-plugin-picker');
        const keyDownTraverseEvent = this._getNamepacedEvent(Plugin.keyDown, 'traverse');

        if (!dropdown.length) {
            return;
        }
        // add key events
        $document.off(keyDownTraverseEvent);
        // istanbul ignore next: not really possible to reproduce focus state in unit tests
        $document.on(keyDownTraverseEvent, function(e) {
            var anchors = dropdown.find('.cms-submenu-item:visible a');
            var index = anchors.index(anchors.filter(':focus'));

            // bind arrow down and tab keys
            if (e.keyCode === KEYS.DOWN || (e.keyCode === KEYS.TAB && !e.shiftKey)) {
                e.preventDefault();
                if (index >= 0 && index < anchors.length - 1) {
                    anchors.eq(index + 1).focus();
                } else {
                    anchors.eq(0).focus();
                }
            }

            // bind arrow up and shift+tab keys
            if (e.keyCode === KEYS.UP || (e.keyCode === KEYS.TAB && e.shiftKey)) {
                e.preventDefault();
                if (anchors.is(':focus')) {
                    anchors.eq(index - 1).focus();
                } else {
                    anchors.eq(anchors.length).focus();
                }
            }
        });
    },

    /**
     * Opens the settings menu for a plugin.
     *
     * @method _showSettingsMenu
     * @private
     * @param {jQuery} nav trigger element
     */
    _showSettingsMenu: function(nav) {
        this._checkIfPasteAllowed();

        var dropdown = this.ui.dropdown;
        var parents = nav.parentsUntil('.cms-dragarea').last();
        var MIN_SCREEN_MARGIN = 10;

        nav.addClass('cms-btn-active');
        parents.addClass('cms-z-index-9999');

        // set visible states
        dropdown.show();

        // calculate dropdown positioning
        if (
            $window.height() + $window.scrollTop() - nav.offset().top - dropdown.height() <= MIN_SCREEN_MARGIN &&
            nav.offset().top - dropdown.height() >= 0
        ) {
            dropdown.removeClass('cms-submenu-dropdown-top').addClass('cms-submenu-dropdown-bottom');
        } else {
            dropdown.removeClass('cms-submenu-dropdown-bottom').addClass('cms-submenu-dropdown-top');
        }
    },

    /**
     * Filters given plugins list by a query.
     *
     * @method _filterPluginsList
     * @private
     * @param {jQuery} list plugins picker element
     * @param {jQuery} input input, which value to filter plugins with
     * @returns {Boolean|void}
     */
    _filterPluginsList: function _filterPluginsList(list, input) {
        var items = list.find('.cms-submenu-item');
        var titles = list.find('.cms-submenu-item-title');
        var query = input.val();

        // cancel if query is zero
        if (query === '') {
            items.add(titles).show();
            return false;
        }

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

        mostRecentItems = mostRecentItems.add(mostRecentItems.nextUntil('.cms-submenu-item-title'));

        var itemsToFilter = items.toArray().map(function(el) {
            var element = $(el);

            return {
                value: element.text(),
                element: element
            };
        });

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

        items.hide();
        filteredItems.forEach(function(item) {
            item.element.show();
        });

        // check if a title is matching
        titles.filter(':visible').each(function(index, item) {
            titles.hide();
            $(item).nextUntil('.cms-submenu-item-title').show();
        });

        // always display title of a category
        items.filter(':visible').each(function(index, titleItem) {
            var item = $(titleItem);

            if (item.prev().hasClass('cms-submenu-item-title')) {
                item.prev().show();
            } else {
                item.prevUntil('.cms-submenu-item-title').last().prev().show();
            }
        });

        mostRecentItems.hide();
    },

    /**
     * Toggles collapsable item.
     *
     * @method _toggleCollapsable
     * @private
     * @param {jQuery} el element to toggle
     * @returns {Boolean|void}
     */
    _toggleCollapsable: function toggleCollapsable(el) {
        var that = this;
        var id = that._getId(el.parent());
        var draggable = el.closest('.cms-draggable');
        var items;

        var settings = CMS.settings;

        settings.states = settings.states || [];

        if (!draggable || !draggable.length) {
            return;
        }

        // collapsable function and save states
        if (el.hasClass('cms-dragitem-expanded')) {
            settings.states.splice($.inArray(id, settings.states), 1);
            el
                .removeClass('cms-dragitem-expanded')
                .parent()
                .find('> .cms-collapsable-container')
                .addClass('cms-hidden');

            if ($document.data('expandmode')) {
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
                if (!items.length) {
                    return false;
                }
                items.each(function() {
                    var item = $(this);

                    if (item.hasClass('cms-dragitem-expanded')) {
                        that._toggleCollapsable(item);
                    }
                });
            }
        } else {
            settings.states.push(id);
            el
                .addClass('cms-dragitem-expanded')
                .parent()
                .find('> .cms-collapsable-container')
                .removeClass('cms-hidden');

            if ($document.data('expandmode')) {
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
                if (!items.length) {
                    return false;
                }
                items.each(function() {
                    var item = $(this);

                    if (!item.hasClass('cms-dragitem-expanded')) {
                        that._toggleCollapsable(item);
                    }
                });
            }
        }

        this._updatePlaceholderCollapseState();

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

        // save settings
        Helpers.setSettings(settings);
    },

    _updatePlaceholderCollapseState() {
        if (this.options.type !== 'plugin' || !this.options.placeholder_id) {
            return;
        }

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

        const openedPlugins = CMS.settings.states;
        const closedPlugins = difference(pluginsOfCurrentPlaceholder, openedPlugins);
        const areAllRemainingPluginsLeafs = every(closedPlugins, id => {
            return !find(
                CMS._plugins,
                ([, o]) => o.placeholder_id === this.options.placeholder_id && o.plugin_parent === id
            );
        });
        const el = $(`.cms-dragarea-${this.options.placeholder_id} .cms-dragbar-title`);
        var settings = CMS.settings;

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

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

            settings.dragbars = settings.dragbars || [];
            settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
        }
    },

    /**
     * Sets up collabspable event handlers.
     *
     * @method _collapsables
     * @private
     * @returns {Boolean|void}
     */
    _collapsables: function() {
        // one time setup
        var that = this;

        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
        // cancel here if its not a draggable
        if (!this.ui.draggable.length) {
            return false;
        }

        var dragitem = this.ui.draggable.find('> .cms-dragitem');

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

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

        // attach events to draggable
        // debounce here required because on some devices click is not triggered,
        // so we consolidate latest click and touch event to run the collapse only once
        dragitem.find('> .cms-dragitem-text').on(
            Plugin.touchEnd + ' ' + Plugin.click,
            debounce(function() {
                if (!dragitem.hasClass('cms-dragitem-collapsable')) {
                    return;
                }
                that._toggleCollapsable(dragitem);
            }, 0)
        );
    },

    /**
     * Expands all the collapsables in the given placeholder.
     *
     * @method _expandAll
     * @private
     * @param {jQuery} el trigger element that is a child of a placeholder
     * @returns {Boolean|void}
     */
    _expandAll: function(el) {
        var that = this;
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');

        // cancel if there are no items
        if (!items.length) {
            return false;
        }
        items.each(function() {
            var item = $(this);

            if (!item.hasClass('cms-dragitem-expanded')) {
                that._toggleCollapsable(item);
            }
        });

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

        var settings = CMS.settings;

        settings.dragbars = settings.dragbars || [];
        settings.dragbars.push(this.options.placeholder_id);
        Helpers.setSettings(settings);
    },

    /**
     * Collapses all the collapsables in the given placeholder.
     *
     * @method _collapseAll
     * @private
     * @param {jQuery} el trigger element that is a child of a placeholder
     */
    _collapseAll: function(el) {
        var that = this;
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');

        items.each(function() {
            var item = $(this);

            if (item.hasClass('cms-dragitem-expanded')) {
                that._toggleCollapsable(item);
            }
        });

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

        var settings = CMS.settings;

        settings.dragbars = settings.dragbars || [];
        settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
        Helpers.setSettings(settings);
    },

    /**
     * Gets the id of the element, uses CMS.StructureBoard instance.
     *
     * @method _getId
     * @private
     * @param {jQuery} el element to get id from
     * @returns {String}
     */
    _getId: function(el) {
        return CMS.API.StructureBoard.getId(el);
    },

    /**
     * Gets the ids of the list of elements, uses CMS.StructureBoard instance.
     *
     * @method _getIds
     * @private
     * @param {jQuery} els elements to get id from
     * @returns {String[]}
     */
    _getIds: function(els) {
        return CMS.API.StructureBoard.getIds(els);
    },

    /**
     * Traverses the registry to find plugin parents
     *
     * @method _getPluginBreadcrumbs
     * @returns {Object[]} array of breadcrumbs in `{ url, title }` format
     * @private
     */
    _getPluginBreadcrumbs: function _getPluginBreadcrumbs() {
        var breadcrumbs = [];

        breadcrumbs.unshift({
            title: this.options.plugin_name,
            url: this.options.urls.edit_plugin
        });

        var findParentPlugin = function(id) {
            return $.grep(CMS._plugins || [], function(pluginOptions) {
                return pluginOptions[0] === 'cms-plugin-' + id;
            })[0];
        };

        var id = this.options.plugin_parent;
        var data;

        while (id && id !== 'None') {
            data = findParentPlugin(id);

            if (!data) {
                break;
            }

            breadcrumbs.unshift({
                title: data[1].plugin_name,
                url: data[1].urls.edit_plugin
            });
            id = data[1].plugin_parent;
        }

        return breadcrumbs;
    }
});

Plugin.click = 'click.cms.plugin';
Plugin.pointerUp = 'pointerup.cms.plugin';
Plugin.pointerDown = 'pointerdown.cms.plugin';
Plugin.pointerOverAndOut = 'pointerover.cms.plugin pointerout.cms.plugin';
Plugin.doubleClick = 'dblclick.cms.plugin';
Plugin.keyUp = 'keyup.cms.plugin';
Plugin.keyDown = 'keydown.cms.plugin';
Plugin.mouseEvents = 'mousedown.cms.plugin mousemove.cms.plugin mouseup.cms.plugin';
Plugin.touchStart = 'touchstart.cms.plugin';
Plugin.touchEnd = 'touchend.cms.plugin';

/**
 * Updates plugin data in CMS._plugins / CMS._instances or creates new
 * plugin instances if they didn't exist
 *
 * @method _updateRegistry
 * @private
 * @static
 * @param {Object[]} plugins plugins data
 */
Plugin._updateRegistry = function _updateRegistry(plugins) {
    plugins.forEach(pluginData => {
        const pluginContainer = `cms-plugin-${pluginData.plugin_id}`;
        const pluginIndex = findIndex(CMS._plugins, ([pluginStr]) => pluginStr === pluginContainer);

        if (pluginIndex === -1) {
            CMS._plugins.push([pluginContainer, pluginData]);
            CMS._instances.push(new Plugin(pluginContainer, pluginData));
        } else {
            Plugin.aliasPluginDuplicatesMap[pluginData.plugin_id] = false;
            CMS._plugins[pluginIndex] = [pluginContainer, pluginData];
            CMS._instances[pluginIndex] = new Plugin(pluginContainer, pluginData);
        }
    });
};

/**
 * Hides the opened settings menu. By default looks for any open ones.
 *
 * @method _hideSettingsMenu
 * @static
 * @private
 * @param {jQuery} [navEl] element representing the subnav trigger
 */
Plugin._hideSettingsMenu = function(navEl) {
    var nav = navEl || $('.cms-submenu-btn.cms-btn-active');

    if (!nav.length) {
        return;
    }
    nav.removeClass('cms-btn-active');

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

    nav.siblings('.cms-submenu-dropdown').hide();
    nav.siblings('.cms-quicksearch').hide();
    // reset search
    nav.siblings('.cms-quicksearch').find('input').val('').trigger(Plugin.keyUp).blur();

    // reset relativity
    $('.cms-dragbar').css('position', '');
};

/**
 * Initialises handlers that affect all plugins and don't make sense
 * in context of each own plugin instance, e.g. listening for a click on a document
 * to hide plugin settings menu should only be applied once, and not every time
 * CMS.Plugin is instantiated.
 *
 * @method _initializeGlobalHandlers
 * @static
 * @private
 */
Plugin._initializeGlobalHandlers = function _initializeGlobalHandlers() {
    var timer;
    var clickCounter = 0;

    Plugin._updateClipboard();

    // Structureboard initialized too late
    setTimeout(function() {
        var pluginData = {};
        var html = '';

        if (clipboardDraggable.length) {
            pluginData = find(
                CMS._plugins,
                ([desc]) => desc === `cms-plugin-${CMS.API.StructureBoard.getId(clipboardDraggable)}`
            )[1];
            html = clipboardDraggable.parent().html();
        }
        if (CMS.API && CMS.API.Clipboard) {
            CMS.API.Clipboard.populate(html, pluginData);
        }
    }, 0);

    $document
        .off(Plugin.pointerUp)
        .off(Plugin.keyDown)
        .off(Plugin.keyUp)
        .off(Plugin.click, '.cms-plugin a, a:has(.cms-plugin), a.cms-plugin')
        .on(Plugin.pointerUp, function() {
            // call it as a static method, because otherwise we trigger it the
            // amount of times CMS.Plugin is instantiated,
            // which does not make much sense.
            Plugin._hideSettingsMenu();
        })
        .on(Plugin.keyDown, function(e) {
            if (e.keyCode === KEYS.SHIFT) {
                $document.data('expandmode', true);
                try {
                    $('.cms-plugin:hover').last().trigger('mouseenter');
                    $('.cms-dragitem:hover').last().trigger('mouseenter');
                } catch (err) {}
            }
        })
        .on(Plugin.keyUp, function(e) {
            if (e.keyCode === KEYS.SHIFT) {
                $document.data('expandmode', false);
                try {
                    $(':hover').trigger('mouseleave');
                } catch (err) {}
            }
        })
        .on(Plugin.click, '.cms-plugin a, a:has(.cms-plugin), a.cms-plugin', function(e) {
            var DOUBLECLICK_DELAY = 300;

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

                    clickCounter = 0;
                    window.open(anchor.attr('href'), anchor.attr('target') || '_self');
                }, DOUBLECLICK_DELAY);
            } else {
                clearTimeout(timer);
                clickCounter = 0;
            }
        });

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

        e.stopPropagation();
        const pluginContainer = $(e.target).closest('.cms-plugin');
        const allOptions = pluginContainer.data('cms');

        if (!allOptions || !allOptions.length) {
            return;
        }

        const options = allOptions[0];

        if (e.type === 'touchstart') {
            CMS.API.Tooltip._forceTouchOnce();
        }
        var name = options.plugin_name;
        var id = options.plugin_id;
        var type = options.type;

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

        if (placeholder.length && placeholder.data('cms')) {
            name = placeholder.data('cms').name + ': ' + name;
        }

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

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

        if (placeholder.hasClass('cms-dragarea-static-expanded') && e.isDefaultPrevented()) {
            return;
        }

        placeholder.toggleClass('cms-dragarea-static-expanded');
    });

    $window.on('blur.cms', () => {
        $document.data('expandmode', false);
    });
};

/**
 * @method _isContainingMultiplePlugins
 * @param {jQuery} node to check
 * @static
 * @private
 * @returns {Boolean}
 */
Plugin._isContainingMultiplePlugins = function _isContainingMultiplePlugins(node) {
    var currentData = node.data('cms');

    // istanbul ignore if
    if (!currentData) {
        throw new Error('Provided node is not a cms plugin.');
    }

    var pluginIds = currentData.map(function(pluginData) {
        return pluginData.plugin_id;
    });

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

    return false;
};

/**
 * Shows and immediately fades out a success notification (when
 * plugin was successfully moved.
 *
 * @method _highlightPluginStructure
 * @private
 * @static
 * @param {jQuery} el draggable element
 */
// eslint-disable-next-line no-magic-numbers
Plugin._highlightPluginStructure = function _highlightPluginStructure(
    el,
    // eslint-disable-next-line no-magic-numbers
    { successTimeout = 200, delay = 1500, seeThrough = false }
) {
    const tpl = $(`
        <div class="cms-dragitem-success ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}">
        </div>
    `);

    el.addClass('cms-draggable-success').append(tpl);
    // start animation
    if (successTimeout) {
        setTimeout(() => {
            tpl.fadeOut(successTimeout, function() {
                $(this).remove();
                el.removeClass('cms-draggable-success');
            });
        }, delay);
    }
    // make sure structurboard gets updated after success
    $(Helpers._getWindow()).trigger('resize.sideframe');
};

/**
 * Highlights plugin in content mode
 *
 * @method _highlightPluginContent
 * @private
 * @static
 * @param {String|Number} pluginId
 */
Plugin._highlightPluginContent = function _highlightPluginContent(
    pluginId,
    // eslint-disable-next-line no-magic-numbers
    { successTimeout = 200, seeThrough = false, delay = 1500, prominent = false } = {}
) {
    var coordinates = {};
    var positions = [];
    var OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO = 0.2;

    $('.cms-plugin-' + pluginId).each(function() {
        var el = $(this);
        var offset = el.offset();
        var ml = parseInt(el.css('margin-left'), 10);
        var mr = parseInt(el.css('margin-right'), 10);
        var mt = parseInt(el.css('margin-top'), 10);
        var mb = parseInt(el.css('margin-bottom'), 10);
        var width = el.outerWidth();
        var height = el.outerHeight();

        if (width === 0 && height === 0) {
            return;
        }

        if (isNaN(ml)) {
            ml = 0;
        }
        if (isNaN(mr)) {
            mr = 0;
        }
        if (isNaN(mt)) {
            mt = 0;
        }
        if (isNaN(mb)) {
            mb = 0;
        }

        positions.push({
            x1: offset.left - ml,
            x2: offset.left + width + mr,
            y1: offset.top - mt,
            y2: offset.top + height + mb
        });
    });

    if (positions.length === 0) {
        return;
    }

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

    coordinates.left = Math.min(...positions.map(pos => pos.x1));
    coordinates.top = Math.min(...positions.map(pos => pos.y1)) - htmlMargin;
    coordinates.width = Math.max(...positions.map(pos => pos.x2)) - coordinates.left;
    coordinates.height = Math.max(...positions.map(pos => pos.y2)) - coordinates.top - htmlMargin;

    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);

    $(
        `
        <div class="
            cms-plugin-overlay
            cms-dragitem-success
            cms-plugin-overlay-${pluginId}
            ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}
            ${prominent ? 'cms-plugin-overlay-prominent' : ''}
        "
            data-success-timeout="${successTimeout}"
        >
        </div>
    `
    )
        .css(coordinates)
        .css({
            zIndex: 9999
        })
        .appendTo($('body'));

    if (successTimeout) {
        setTimeout(() => {
            $(`.cms-plugin-overlay-${pluginId}`).fadeOut(successTimeout, function() {
                $(this).remove();
            });
        }, delay);
    }
};

Plugin._clickToHighlightHandler = function _clickToHighlightHandler(e) {
    if (CMS.settings.mode !== 'structure') {
        return;
    }
    e.preventDefault();
    e.stopPropagation();
    // FIXME refactor into an object
    CMS.API.StructureBoard._showAndHighlightPlugin(200, true); // eslint-disable-line no-magic-numbers
};

Plugin._removeHighlightPluginContent = function(pluginId) {
    $(`.cms-plugin-overlay-${pluginId}[data-success-timeout=0]`).remove();
};

Plugin.aliasPluginDuplicatesMap = {};
Plugin.staticPlaceholderDuplicatesMap = {};

// istanbul ignore next
Plugin._initializeTree = function _initializeTree() {
    CMS._plugins = uniqWith(CMS._plugins, ([x], [y]) => x === y);
    CMS._instances = CMS._plugins.map(function(args) {
        return new CMS.Plugin(args[0], args[1]);
    });

    // return the cms plugin instances just created
    return CMS._instances;
};

Plugin._updateClipboard = function _updateClipboard() {
    clipboardDraggable = $('.cms-draggable-from-clipboard:first');
};

Plugin._updateUsageCount = function _updateUsageCount(pluginType) {
    var currentValue = pluginUsageMap[pluginType] || 0;

    pluginUsageMap[pluginType] = currentValue + 1;

    if (Helpers._isStorageSupported) {
        localStorage.setItem('cms-plugin-usage', JSON.stringify(pluginUsageMap));
    }
};

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

Plugin._refreshPlugins = function refreshPlugins() {
    Plugin.aliasPluginDuplicatesMap = {};
    Plugin.staticPlaceholderDuplicatesMap = {};
    CMS._plugins = uniqWith(CMS._plugins, isEqual);

    CMS._instances.forEach(instance => {
        if (instance.options.type === 'placeholder') {
            instance._setupUI(`cms-placeholder-${instance.options.placeholder_id}`);
            instance._ensureData();
            instance.ui.container.data('cms', instance.options);
            instance._setPlaceholder();
        }
    });

    CMS._instances.forEach(instance => {
        if (instance.options.type === 'plugin') {
            instance._setupUI(`cms-plugin-${instance.options.plugin_id}`);
            instance._ensureData();
            instance.ui.container.data('cms').push(instance.options);
            instance._setPluginContentEvents();
        }
    });

    CMS._plugins.forEach(([type, opts]) => {
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
            const instance = find(
                CMS._instances,
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
            );

            if (instance) {
                // update
                instance._setupUI(type);
                instance._ensureData();
                instance.ui.container.data('cms').push(instance.options);
                instance._setGeneric();
            } else {
                // create
                CMS._instances.push(new Plugin(type, opts));
            }
        }
    });
};

// shorthand for jQuery(document).ready();
$(Plugin._initializeGlobalHandlers);

export default Plugin;