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

divio / django-cms / #29421

pending completion
#29421

push

travis-ci

web-flow
Merge 86cfe702d into 733c377ab

1060 of 1604 branches covered (66.08%)

137 of 190 new or added lines in 3 files covered. (72.11%)

259 existing lines in 2 files now uncovered.

2537 of 3380 relevant lines covered (75.06%)

26.4 hits per line

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

86.71
/cms/static/cms/js/modules/cms.structureboard.js
1
/*
2
 * Copyright https://github.com/divio/django-cms
3
 */
4

5
/* eslint-env es6 */
6
/* jshint esversion: 6 */
7
/* global CMS */
8

9
import $ from 'jquery';
10
import keyboard from './keyboard';
11
import Plugin from './cms.plugins';
12
import { getPlaceholderIds } from './cms.toolbar';
13
import Clipboard from './cms.clipboard';
14
import { DiffDOM, nodeToObj } from 'diff-dom';
15
import PreventParentScroll from 'prevent-parent-scroll';
16
import { find, findIndex, once, remove, compact, isEqual, zip, every } from 'lodash';
17
import ls from 'local-storage';
18

19
import './jquery.ui.custom';
20
import './jquery.ui.touchpunch';
21
import './jquery.ui.nestedsortable';
22

23
import measureScrollbar from './scrollbar';
24
import preloadImagesFromMarkup from './preload-images';
25

26
import { Helpers, KEYS } from './cms.base';
27
import { showLoader, hideLoader } from './loader';
28

29
const DOMParser = window.DOMParser; // needed only for testing
1✔
30
const storageKey = 'cms-structure';
1✔
31

32
let dd;
33
let placeholders;
34
let originalPluginContainer;
35

36
const triggerWindowResize = () => {
1✔
37
    'use strict';
38
    try {
49✔
39
        const evt = document.createEvent('UIEvents');
49✔
40

41
        evt.initUIEvent('resize', true, false, window, 0);
49✔
42
        window.dispatchEvent(evt);
49✔
43
    } catch (e) {}
44
};
45

46
const arrayEquals = (a1, a2) => every(zip(a1, a2), ([a, b]) => a === b);
2✔
47

48
/**
49
 * Handles drag & drop, mode switching and collapsables.
50
 *
51
 * @class StructureBoard
52
 * @namespace CMS
53
 */
54
class StructureBoard {
55
    constructor() {
56
        // elements
57
        this._setupUI();
126✔
58

59
        // states
60
        this.click = 'click.cms.structure';
126✔
61
        this.keyUpAndDown = 'keyup.cms.structure keydown.cms.structure';
126✔
62
        this.pointerUp = 'pointerup.cms';
126✔
63
        this.state = false;
126✔
64
        this.dragging = false;
126✔
65
        this.latestAction = [];
126✔
66
        this.scriptReferenceCount = 0;
126✔
67
        ls.remove(storageKey);
126✔
68

69
        dd = new DiffDOM();
126✔
70

71
        // setup initial stuff
72
        const setup = this._setup();
126✔
73

74
        // istanbul ignore if
75
        if (typeof setup === 'undefined' && CMS.config.mode === 'draft') {
126✔
76
            this._preloadOppositeMode();
77
        }
78
        this._setupModeSwitcher();
126✔
79
        this._events();
126✔
80
        StructureBoard.actualizePlaceholders();
126✔
81

82
        setTimeout(() => this.highlightPluginFromUrl(), 0);
126✔
83
        this._listenToExternalUpdates();
126✔
84
    }
85

86
    /**
87
     * Stores all jQuery references within `this.ui`.
88
     *
89
     * @method _setupUI
90
     * @private
91
     */
92
    _setupUI() {
93
        const container = $('.cms-structure');
126✔
94
        const toolbar = $('.cms-toolbar');
126✔
95

96
        this.ui = {
126✔
97
            container: container,
98
            content: $('.cms-structure-content'),
99
            doc: $(document),
100
            window: $(window),
101
            html: $('html'),
102
            toolbar: toolbar,
103
            sortables: $('.cms-draggables'), // global scope to include clipboard
104
            plugins: $('.cms-plugin'),
105
            render_model: $('.cms-render-model'),
106
            placeholders: $('.cms-placeholder'),
107
            dragitems: $('.cms-draggable'),
108
            dragareas: $('.cms-dragarea'),
109
            toolbarModeSwitcher: toolbar.find('.cms-toolbar-item-cms-mode-switcher'),
110
            toolbarModeLinks: toolbar.find('.cms-toolbar-item-cms-mode-switcher a')
111
        };
112

113
        this._preventScroll = new PreventParentScroll(this.ui.content[0]);
126✔
114
    }
115

116
    /**
117
     * Initial setup (and early bail if specific
118
     * elements do not exist).
119
     *
120
     * @method _setup
121
     * @private
122
     * @returns {Boolean|void}
123
     */
124
    _setup() {
125
        const that = this;
126✔
126

127
        // cancel if there is no structure / content switcher
128
        if (!this.ui.toolbarModeSwitcher.length) {
126✔
129
            return false;
19✔
130
        }
131

132
        // setup toolbar mode
133
        if (CMS.config.settings.mode === 'structure') {
107✔
134
            that.show({ init: true });
62✔
135
            that._loadedStructure = true;
62✔
136
            StructureBoard._initializeDragItemsStates();
62✔
137
        } else {
138
            // triggering hide here to switch proper classnames on switcher
139
            that.hide();
45✔
140
            that._loadedContent = true;
45✔
141
        }
142

143
        if (CMS.config.settings.legacy_mode) {
107✔
144
            that._loadedStructure = true;
1✔
145
            that._loadedContent = true;
1✔
146
        }
147

148
        // check if modes should be visible
149
        if (this.ui.dragareas.not('.cms-clipboard .cms-dragarea').length || this.ui.placeholders.length) {
107✔
150
            // eslint-disable-line
151
            this.ui.toolbarModeSwitcher.find('.cms-btn').removeClass('cms-btn-disabled');
105✔
152
        }
153

154
        // add drag & drop functionality
155
        // istanbul ignore next
156
        $('.cms-draggable:not(.cms-drag-disabled)').one(
157
            'pointerover.cms.drag',
158
            once(() => {
159
                $('.cms-draggable').off('pointerover.cms.drag');
160
                this._drag();
161
            })
162
        );
163
    }
164

165
    _preloadOppositeMode() {
166
        if (CMS.config.settings.legacy_mode) {
3✔
167
            return;
1✔
168
        }
169
        const WAIT_BEFORE_PRELOADING = 2000;
2✔
170

171
        $(Helpers._getWindow()).one('load', () => {
2✔
172
            setTimeout(() => {
2✔
173
                if (this._loadedStructure) {
2✔
174
                    this._requestMode('content');
1✔
175
                } else {
176
                    this._requestMode('structure');
1✔
177
                }
178
            }, WAIT_BEFORE_PRELOADING);
179
        });
180
    }
181

182
    _events() {
183
        this.ui.window.on('resize.cms.structureboard', () => {
126✔
184
            if (!this._loadedContent || CMS.config.mode !== 'draft') {
8,853!
185
                return;
8,853✔
186
            }
UNCOV
187
            const width = this.ui.window[0].innerWidth;
×
UNCOV
188
            const BREAKPOINT = 1024;
×
189

UNCOV
190
            if (width > BREAKPOINT && !this.condensed) {
×
UNCOV
191
                this._makeCondensed();
×
192
            }
193

194
            if (width <= BREAKPOINT && this.condensed) {
×
UNCOV
195
                this._makeFullWidth();
×
196
            }
197
        });
198
    }
199

200
    /**
201
     * Sets up events handlers for switching
202
     * structureboard modes.
203
     *
204
     * @method _setupModeSwitcher
205
     * @private
206
     */
207
    _setupModeSwitcher() {
208
        const modes = this.ui.toolbarModeLinks;
126✔
209
        let cmdPressed;
210

211
        $(Helpers._getWindow())
126✔
212
            .on(this.keyUpAndDown, e => {
UNCOV
213
                if (
×
214
                    e.keyCode === KEYS.CMD_LEFT ||
×
215
                    e.keyCode === KEYS.CMD_RIGHT ||
216
                    e.keyCode === KEYS.CMD_FIREFOX ||
217
                    e.keyCode === KEYS.SHIFT ||
218
                    e.keyCode === KEYS.CTRL
219
                ) {
UNCOV
220
                    cmdPressed = true;
×
221
                }
UNCOV
222
                if (e.type === 'keyup') {
×
UNCOV
223
                    cmdPressed = false;
×
224
                }
225
            })
226
            .on('blur', () => {
UNCOV
227
                cmdPressed = false;
×
228
            });
229

230
        // show edit mode
231
        modes.on(this.click, e => {
126✔
232
            e.preventDefault();
4✔
233
            e.stopImmediatePropagation();
4✔
234

235
            if (modes.hasClass('cms-btn-disabled')) {
4!
UNCOV
236
                return;
×
237
            }
238

239
            if (cmdPressed && e.type === 'click') {
4!
240
                // control the behaviour when ctrl/cmd is pressed
UNCOV
241
                Helpers._getWindow().open(modes.attr('href'), '_blank');
×
242
                return;
×
243
            }
244

245
            if (CMS.settings.mode === 'edit') {
4✔
246
                this.show();
2✔
247
            } else {
248
                this.hide();
2✔
249
            }
250
        });
251

252
        // keyboard handling
253
        // only if there is a structure / content switcher
254
        if (
126✔
255
            this.ui.toolbarModeSwitcher.length &&
233✔
256
            !this.ui.toolbarModeSwitcher.find('.cms-btn').is('.cms-btn-disabled')
257
        ) {
258
            keyboard.setContext('cms');
105✔
259
            keyboard.bind('space', e => {
105✔
260
                e.preventDefault();
1✔
261
                this._toggleStructureBoard();
1✔
262
            });
263
            keyboard.bind('shift+space', e => {
105✔
264
                e.preventDefault();
1✔
265
                this._toggleStructureBoard({ useHoveredPlugin: true });
1✔
266
            });
267
        }
268
    }
269

270
    /**
271
     * @method _toggleStructureBoard
272
     * @private
273
     * @param {Object} [options] options
274
     * @param {Boolean} [options.useHoveredPlugin] should the plugin be taken into account
275
     */
276
    _toggleStructureBoard(options = {}) {
2✔
277
        const that = this;
4✔
278

279
        if (options.useHoveredPlugin && CMS.settings.mode !== 'structure') {
4✔
280
            that._showAndHighlightPlugin(options.successTimeout).then($.noop, $.noop);
1✔
281
        } else if (!options.useHoveredPlugin) {
3✔
282
            // eslint-disable-next-line no-lonely-if
283
            if (CMS.settings.mode === 'structure') {
2✔
284
                that.hide();
1✔
285
            } else if (CMS.settings.mode === 'edit') {
1!
286
                /* istanbul ignore else */ that.show();
1✔
287
            }
288
        }
289
    }
290

291
    /**
292
     * Shows structureboard, scrolls into view and highlights hovered plugin.
293
     * Uses CMS.API.Tooltip because it already handles multiple plugins living on
294
     * the same DOM node.
295
     *
296
     * @method _showAndHighlightPlugin
297
     * @private
298
     * @returns {Promise}
299
     */
300
    // eslint-disable-next-line no-magic-numbers
301
    _showAndHighlightPlugin(successTimeout = 200, seeThrough = false) {
8✔
302
        // cancel show if live modus is active
303
        if (CMS.config.mode === 'live') {
4✔
304
            return Promise.resolve(false);
1✔
305
        }
306

307
        if (!CMS.API.Tooltip) {
3✔
308
            return Promise.resolve(false);
1✔
309
        }
310

311
        const tooltip = CMS.API.Tooltip.domElem;
2✔
312
        const HIGHLIGHT_TIMEOUT = 10;
2✔
313
        const DRAGGABLE_HEIGHT = 50; // it's not precisely 50, but it fits
2✔
314

315
        if (!tooltip.is(':visible')) {
2✔
316
            return Promise.resolve(false);
1✔
317
        }
318

319
        const pluginId = tooltip.data('plugin_id');
1✔
320

321
        return this.show().then(function() {
1✔
322
            const draggable = $('.cms-draggable-' + pluginId);
1✔
323
            const doc = $(document);
1✔
324
            const currentExpandmode = doc.data('expandmode');
1✔
325

326
            // expand necessary parents
327
            doc.data('expandmode', false);
1✔
328
            draggable
1✔
329
                .parents('.cms-draggable')
330
                .find('> .cms-dragitem-collapsable:not(".cms-dragitem-expanded") > .cms-dragitem-text')
UNCOV
331
                .each((i, el) => $(el).triggerHandler(Plugin.click));
×
332

333
            setTimeout(() => doc.data('expandmode', currentExpandmode));
1✔
334
            setTimeout(function() {
1✔
335
                const offsetParent = draggable.offsetParent();
1✔
336
                const position = draggable.position().top + offsetParent.scrollTop();
1✔
337

338
                draggable.offsetParent().scrollTop(position - window.innerHeight / 2 + DRAGGABLE_HEIGHT);
1✔
339

340
                Plugin._highlightPluginStructure(draggable.find('.cms-dragitem:first'), { successTimeout, seeThrough });
1✔
341
            }, HIGHLIGHT_TIMEOUT);
342
        });
343
    }
344

345
    /**
346
     * Shows the structureboard. (Structure mode)
347
     *
348
     * @method show
349
     * @public
350
     * @param {Boolean} init true if this is first initialization
351
     * @returns {Promise}
352
     */
353
    show({ init = false } = {}) {
88✔
354
        // cancel show if live modus is active
355
        if (CMS.config.mode === 'live') {
105✔
356
            return Promise.resolve(false);
1✔
357
        }
358

359
        // in order to get consistent positioning
360
        // of the toolbar we have to know if the page
361
        // had the scrollbar and if it had - we adjust
362
        // the toolbar positioning
363
        if (init) {
104✔
364
            const width = this.ui.toolbar.width();
61✔
365
            let scrollBarWidth = this.ui.window[0].innerWidth - width;
61✔
366

367
            if (!scrollBarWidth && init) {
61!
368
                scrollBarWidth = measureScrollbar();
61✔
369
            }
370

371
            if (scrollBarWidth) {
61!
372
                this.ui.toolbar.css('right', scrollBarWidth);
61✔
373
            }
374
        }
375
        // apply new settings
376
        CMS.settings.mode = 'structure';
104✔
377
        Helpers.setSettings(CMS.settings);
104✔
378

379
        return this._loadStructure().then(this._showBoard.bind(this, init));
104✔
380
    }
381

382
    _loadStructure() {
383
        // case when structure mode is already loaded
384
        if (CMS.config.settings.mode === 'structure' || this._loadedStructure) {
100✔
385
            return Promise.resolve();
99✔
386
        }
387

388
        showLoader();
1✔
389
        return this
1✔
390
            ._requestMode('structure')
391
            .done(contentMarkup => {
392
                this._requeststructure = null;
1✔
393
                hideLoader();
1✔
394

395
                CMS.settings.states = Helpers.getSettings().states;
1✔
396

397
                const bodyRegex = /<body[\S\s]*?>([\S\s]*)<\/body>/gi;
1✔
398
                const body = $(bodyRegex.exec(contentMarkup)[1]);
1✔
399

400
                const structure = body.find('.cms-structure-content');
1✔
401
                const toolbar = body.find('.cms-toolbar');
1✔
402
                const scripts = body.filter(function() {
1✔
403
                    return $(this).is('[type="text/cms-template"]'); // cms scripts
1✔
404
                });
405
                const pluginIds = this.getIds(body.find('.cms-draggable'));
1✔
406
                const pluginDataSource = body.filter('script[data-cms]').toArray()
1✔
UNCOV
407
                    .map(script => script.textContent || '').join();
×
408
                const pluginData = StructureBoard._getPluginDataFromMarkup(
1✔
409
                    pluginDataSource,
410
                    pluginIds
411
                );
412

413
                Plugin._updateRegistry(pluginData.map(([, data]) => data));
1✔
414

415
                CMS.API.Toolbar._refreshMarkup(toolbar);
1✔
416

417
                $('body').append(scripts);
1✔
418
                $('.cms-structure-content').html(structure.html());
1✔
419
                triggerWindowResize();
1✔
420

421
                StructureBoard._initializeGlobalHandlers();
1✔
422
                StructureBoard.actualizePlaceholders();
1✔
423
                CMS._instances.forEach(instance => {
1✔
424
                    if (instance.options.type === 'placeholder') {
3✔
425
                        instance._setPlaceholder();
1✔
426
                    }
427
                });
428
                CMS._instances.forEach(instance => {
1✔
429
                    if (instance.options.type === 'plugin') {
3✔
430
                        instance._setPluginStructureEvents();
1✔
431
                        instance._collapsables();
1✔
432
                    }
433
                });
434

435
                this.ui.sortables = $('.cms-draggables');
1✔
436
                this._drag();
1✔
437
                StructureBoard._initializeDragItemsStates();
1✔
438

439
                this._loadedStructure = true;
1✔
440
            })
441
            .fail(function() {
UNCOV
442
                window.location.href = CMS.config.settings.structure;
×
443
            });
444
    }
445

446
    _requestMode(mode) {
447
        let url;
448

449
        if (mode === 'structure') {
4✔
450
            url = CMS.config.settings.structure;
1✔
451
        } else {
452
            url = CMS.config.settings.edit;
3✔
453
        }
454

455
        if (!this[`_request${mode}`]) {
4✔
456
            this[`_request${mode}`] = $.ajax({
3✔
457
                url: url.toString(),
458
                method: 'GET'
459
            }).then(markup => {
460
                preloadImagesFromMarkup(markup);
3✔
461

462
                return markup;
3✔
463
            });
464
        }
465

466
        return this[`_request${mode}`];
4✔
467
    }
468

469
    _loadContent() {
470
        const that = this;
47✔
471

472
        // case when content mode is already loaded
473
        if (CMS.config.settings.mode === 'edit' || this._loadedContent) {
47✔
474
            return Promise.resolve();
46✔
475
        }
476

477
        showLoader();
1✔
478
        return that
1✔
479
            ._requestMode('content')
480
            .done(function(contentMarkup) {
481
                that._requestcontent = null;
1✔
482
                hideLoader();
1✔
483
                const htmlRegex = /<html([\S\s]*?)>[\S\s]*<\/html>/gi;
1✔
484
                const bodyRegex = /<body([\S\s]*?)>([\S\s]*)<\/body>/gi;
1✔
485
                const headRegex = /<head[\S\s]*?>([\S\s]*)<\/head>/gi;
1✔
486
                const matches = bodyRegex.exec(contentMarkup);
1✔
487
                // we don't handle cases where body or html doesn't exist, cause it's highly unlikely
488
                // and will result in way more troubles for cms than this
489
                const bodyAttrs = matches[1];
1✔
490
                let body = $(matches[2]);
1✔
491
                const head = $(headRegex.exec(contentMarkup)[1]);
1✔
492
                const htmlAttrs = htmlRegex.exec(contentMarkup)[1];
1✔
493
                const bodyAttributes = $('<div ' + bodyAttrs + '></div>')[0].attributes;
1✔
494
                const htmlAttributes = $('<div ' + htmlAttrs + '></div>')[0].attributes;
1✔
495
                const newToolbar = body.find('.cms-toolbar');
1✔
496
                const toolbar = $('.cms').add('[data-cms]').detach();
1✔
497
                const title = head.filter('title');
1✔
498
                const bodyElement = $('body');
1✔
499

500
                // istanbul ignore else
501
                if (title) {
1✔
502
                    document.title = title.text();
1✔
503
                }
504

505
                body = body.filter(function() {
1✔
506
                    const elem = $(this);
1✔
507

508
                    return (
1✔
509
                        !elem.is('.cms#cms-top') && !elem.is('[data-cms]:not([data-cms-generic])') // toolbar
2✔
510
                    ); // cms scripts
511
                });
512
                body.find('[data-cms]:not([data-cms-generic])').remove(); // cms scripts
1✔
513

514
                [].slice.call(bodyAttributes).forEach(function(attr) {
1✔
515
                    bodyElement.attr(attr.name, attr.value);
2✔
516
                });
517

518
                [].slice.call(htmlAttributes).forEach(function(attr) {
1✔
519
                    $('html').attr(attr.name, attr.value);
2✔
520
                });
521

522
                bodyElement.append(body);
1✔
523
                $('head').append(head);
1✔
524
                bodyElement.prepend(toolbar);
1✔
525

526
                CMS.API.Toolbar._refreshMarkup(newToolbar);
1✔
527
                $(window).trigger('resize');
1✔
528

529
                Plugin._refreshPlugins();
1✔
530

531
                const scripts = $('script');
1✔
532

533
                // istanbul ignore next
534
                scripts.on('load', function() {
535
                    window.dispatchEvent(new Event('load'));
536
                    window.dispatchEvent(new Event('DOMContentLoaded'));
537
                });
538

539
                const unhandledPlugins = bodyElement.find('template.cms-plugin');
1✔
540

541
                if (unhandledPlugins.length) {
1!
UNCOV
542
                    CMS.API.Messages.open({
×
543
                        message: CMS.config.lang.unhandledPageChange
544
                    });
NEW
545
                    Helpers.reloadBrowser('REFRESH_PAGE');
×
546
                }
547

548
                that._loadedContent = true;
1✔
549
            })
550
            .fail(function() {
UNCOV
551
                window.location.href = CMS.config.settings.edit;
×
552
            });
553
    }
554

555
    /**
556
     * Hides the structureboard. (Content mode)
557
     *
558
     * @method hide
559
     * @returns {Boolean|void}
560
     */
561
    hide() {
562
        // cancel show if live modus is active
563
        if (CMS.config.mode === 'live') {
49✔
564
            return false;
1✔
565
        }
566

567
        // reset toolbar positioning
568
        this.ui.toolbar.css('right', '');
48✔
569
        $('html').removeClass('cms-overflow');
48✔
570

571
        // set active item
572
        const modes = this.ui.toolbarModeLinks;
48✔
573

574
        modes.removeClass('cms-btn-active').eq(1).addClass('cms-btn-active');
48✔
575
        this.ui.html.removeClass('cms-structure-mode-structure').addClass('cms-structure-mode-content');
48✔
576

577
        CMS.settings.mode = 'edit';
48✔
578

579
        // hide canvas
580
        return this._loadContent().then(this._hideBoard.bind(this));
48✔
581
    }
582

583
    /**
584
     * Gets the id of the element.
585
     * relies on cms-{item}-{id} to always be second in a string of classes (!)
586
     *
587
     * @method getId
588
     * @param {jQuery} el element to get id from
589
     * @returns {String}
590
     */
591
    getId(el) {
592
        // cancel if no element is defined
593
        if (el === undefined || el === null || el.length <= 0) {
73✔
594
            return false;
7✔
595
        }
596

597
        let id = null;
66✔
598
        const cls = el.attr('class').split(' ')[1];
66✔
599

600
        if (el.hasClass('cms-plugin')) {
66✔
601
            id = cls.replace('cms-plugin-', '').trim();
10✔
602
        } else if (el.hasClass('cms-draggable')) {
56✔
603
            id = cls.replace('cms-draggable-', '').trim();
36✔
604
        } else if (el.hasClass('cms-placeholder')) {
20✔
605
            id = cls.replace('cms-placeholder-', '').trim();
2✔
606
        } else if (el.hasClass('cms-dragbar')) {
18✔
607
            id = cls.replace('cms-dragbar-', '').trim();
2✔
608
        } else if (el.hasClass('cms-dragarea')) {
16✔
609
            id = cls.replace('cms-dragarea-', '').trim();
11✔
610
        }
611

612
        return id;
65✔
613
    }
614

615
    /**
616
     * Gets the ids of the list of  elements.
617
     *
618
     * @method getIds
619
     * @param {jQuery} els elements to get id from
620
     * @returns {String[]}
621
     */
622
    getIds(els) {
623
        const that = this;
8✔
624
        const array = [];
8✔
625

626
        els.each(function() {
8✔
627
            array.push(that.getId($(this)));
13✔
628
        });
629
        return array;
8✔
630
    }
631

632
    /**
633
     * Actually shows the board canvas.
634
     *
635
     * @method _showBoard
636
     * @param {Boolean} init init
637
     * @private
638
     */
639
    _showBoard(init) {
640
        // set active item
641
        const modes = this.ui.toolbarModeLinks;
104✔
642

643
        modes.removeClass('cms-btn-active').eq(0).addClass('cms-btn-active');
104✔
644
        this.ui.html.removeClass('cms-structure-mode-content').addClass('cms-structure-mode-structure');
104✔
645

646
        this.ui.container.show();
104✔
647
        hideLoader();
104✔
648

649
        if (!init) {
104✔
650
            this._makeCondensed();
43✔
651
        }
652

653
        if (init && !this._loadedContent) {
104✔
654
            this._makeFullWidth();
61✔
655
        }
656

657
        this._preventScroll.start();
104✔
658
        this.ui.window.trigger('resize');
104✔
659
    }
660

661
    _makeCondensed() {
662
        this.condensed = true;
43✔
663
        this.ui.container.addClass('cms-structure-condensed');
43✔
664

665
        if (CMS.settings.mode === 'structure') {
43✔
666
            history.replaceState({}, '', CMS.config.settings.edit);
42✔
667
        }
668

669
        const width = this.ui.toolbar.width();
43✔
670
        let scrollBarWidth = this.ui.window[0].innerWidth - width;
43✔
671

672
        if (!scrollBarWidth) {
43✔
673
            scrollBarWidth = measureScrollbar();
6✔
674
        }
675

676
        this.ui.html.removeClass('cms-overflow');
43✔
677

678
        if (scrollBarWidth) {
43!
679
            // this.ui.toolbar.css('right', scrollBarWidth);
680
            this.ui.container.css('right', -scrollBarWidth);
43✔
681
        }
682
    }
683

684
    _makeFullWidth() {
685
        this.condensed = false;
61✔
686
        this.ui.container.removeClass('cms-structure-condensed');
61✔
687

688
        if (CMS.settings.mode === 'structure') {
61!
689
            history.replaceState({}, '', CMS.config.settings.structure);
61✔
690
            $('html.cms-structure-mode-structure').addClass('cms-overflow');
61✔
691
        }
692

693
        this.ui.container.css('right', 0);
61✔
694
    }
695

696
    /**
697
     * Hides the board canvas.
698
     *
699
     * @method _hideBoard
700
     * @private
701
     */
702
    _hideBoard() {
703
        // hide elements
704
        this.ui.container.hide();
48✔
705
        this._preventScroll.stop();
48✔
706

707
        // this is sometimes required for user-side scripts to
708
        // render dynamic elements on the page correctly.
709
        // e.g. you have a parallax script that calculates position
710
        // of elements based on document height. but if the page is
711
        // loaded with structureboard active - the document height
712
        // would be same as screen height, which is likely incorrect,
713
        // so triggering resize on window would force user scripts
714
        // to recalculate whatever is required there
715
        // istanbul ignore catch
716
        triggerWindowResize();
48✔
717
    }
718

719
    /**
720
     * Sets up all the sortables.
721
     *
722
     * @method _drag
723
     * @param {jQuery} [elem=this.ui.sortables] which element to initialize
724
     * @private
725
     */
726
    _drag(elem = this.ui.sortables) {
36✔
727
        const that = this;
36✔
728

729
        elem
36✔
730
            .nestedSortable({
731
                items: '> .cms-draggable:not(.cms-draggable-disabled .cms-draggable)',
732
                placeholder: 'cms-droppable',
733
                connectWith: '.cms-draggables:not(.cms-hidden)',
734
                tolerance: 'intersect',
735
                toleranceElement: '> div',
736
                dropOnEmpty: true,
737
                // cloning huge structure is a performance loss compared to cloning just a dragitem
738
                helper: function createHelper(e, item) {
739
                    const clone = item.find('> .cms-dragitem').clone();
8✔
740

741
                    clone.wrap('<div class="' + item[0].className + '"></div>');
8✔
742
                    return clone.parent();
8✔
743
                },
744
                appendTo: '.cms-structure-content',
745
                // appendTo: '.cms',
746
                cursor: 'move',
747
                cursorAt: { left: -15, top: -15 },
748
                opacity: 1,
749
                zIndex: 9999999,
750
                delay: 100,
751
                tabSize: 15,
752
                // nestedSortable
753
                listType: 'div.cms-draggables',
754
                doNotClear: true,
755
                disableNestingClass: 'cms-draggable-disabled',
756
                errorClass: 'cms-draggable-disallowed',
757
                scrollSpeed: 15,
758
                // eslint-disable-next-line no-magic-numbers
759
                scrollSensitivity: that.ui.window.height() * 0.2,
760
                start: function(e, ui) {
761
                    that.ui.content.attr('data-touch-action', 'none');
20✔
762

763
                    originalPluginContainer = ui.item.closest('.cms-draggables');
20✔
764

765
                    that.dragging = true;
20✔
766
                    // show empty
767
                    StructureBoard.actualizePlaceholders();
20✔
768
                    // ensure all menus are closed
769
                    Plugin._hideSettingsMenu();
20✔
770
                    // keep in mind that caching cms-draggables query only works
771
                    // as long as we don't create them on the fly
772
                    that.ui.sortables.each(function() {
20✔
773
                        const element = $(this);
80✔
774

775
                        if (element.children().length === 0) {
80✔
776
                            element.removeClass('cms-hidden');
18✔
777
                        }
778
                    });
779

780
                    // fixes placeholder height
781
                    ui.item.addClass('cms-is-dragging');
20✔
782
                    ui.helper.addClass('cms-draggable-is-dragging');
20✔
783
                    if (ui.item.find('> .cms-draggables').children().length) {
20✔
784
                        ui.helper.addClass('cms-draggable-stack');
1✔
785
                    }
786

787
                    // attach escape event to cancel dragging
788
                    that.ui.doc.on('keyup.cms.interrupt', function(event, cancel) {
20✔
789
                        if ((event.keyCode === KEYS.ESC && that.dragging) || cancel) {
3✔
790
                            that.state = false;
2✔
791
                            $.ui.sortable.prototype._mouseStop();
2✔
792
                            that.ui.sortables.trigger('mouseup');
2✔
793
                        }
794
                    });
795
                },
796

797
                beforeStop: function(event, ui) {
798
                    that.dragging = false;
4✔
799
                    ui.item.removeClass('cms-is-dragging cms-draggable-stack');
4✔
800
                    that.ui.doc.off('keyup.cms.interrupt');
4✔
801
                    that.ui.content.attr('data-touch-action', 'pan-y');
4✔
802
                },
803

804
                update: function(event, ui) {
805
                    // cancel if isAllowed returns false
806
                    if (!that.state) {
12✔
807
                        return false;
1✔
808
                    }
809

810
                    const newPluginContainer = ui.item.closest('.cms-draggables');
11✔
811

812
                    if (originalPluginContainer.is(newPluginContainer)) {
11✔
813
                        // if we moved inside same container,
814
                        // but event is fired on a parent, discard update
815
                        if (!newPluginContainer.is(this)) {
2✔
816
                            return false;
1✔
817
                        }
818
                    } else {
819
                        StructureBoard.actualizePluginsCollapsibleStatus(
9✔
820
                            newPluginContainer.add(originalPluginContainer)
821
                        );
822
                    }
823

824
                    // we pass the id to the updater which checks within the backend the correct place
825
                    const id = that.getId(ui.item);
10✔
826
                    const plugin = $(`.cms-draggable-${id}`);
10✔
827
                    const eventData = {
10✔
828
                        id: id
829
                    };
830
                    const previousParentPlugin = originalPluginContainer.closest('.cms-draggable');
10✔
831

832
                    if (previousParentPlugin.length) {
10✔
833
                        eventData.previousParentPluginId = that.getId(previousParentPlugin);
3✔
834
                    }
835

836
                    // check if we copy/paste a plugin or not
837
                    if (originalPluginContainer.hasClass('cms-clipboard-containers')) {
10✔
838
                        originalPluginContainer.html(plugin.eq(0).clone(true, true));
1✔
839
                        Plugin._updateClipboard();
1✔
840
                        plugin.trigger('cms-paste-plugin-update', [eventData]);
1✔
841
                    } else {
842
                        plugin.trigger('cms-plugins-update', [eventData]);
9✔
843
                    }
844

845
                    // reset placeholder without entries
846
                    that.ui.sortables.each(function() {
10✔
847
                        const element = $(this);
40✔
848

849
                        if (element.children().length === 0) {
40✔
850
                            element.addClass('cms-hidden');
7✔
851
                        }
852
                    });
853

854
                    StructureBoard.actualizePlaceholders();
10✔
855
                },
856
                // eslint-disable-next-line complexity
857
                isAllowed: function(placeholder, placeholderParent, originalItem) {
858
                    // cancel if action is executed
859
                    if (CMS.API.locked) {
14✔
860
                        return false;
1✔
861
                    }
862
                    // getting restriction array
863
                    let bounds = [];
13✔
864
                    let immediateParentType;
865

866
                    if (placeholder && placeholder.closest('.cms-clipboard-containers').length) {
13✔
867
                        return false;
1✔
868
                    }
869

870
                    // if parent has class disabled, dissalow drop
871
                    if (placeholder && placeholder.parent().hasClass('cms-draggable-disabled')) {
12✔
872
                        return false;
1✔
873
                    }
874

875
                    const originalItemId = that.getId(originalItem);
11✔
876
                    // save original state events
877
                    const original = $('.cms-draggable-' + originalItemId);
11✔
878

879
                    // cancel if item has no settings
880
                    if (original.length === 0 || !original.data('cms')) {
11✔
881
                        return false;
2✔
882
                    }
883
                    const originalItemData = original.data('cms');
9✔
884
                    const parent_bounds = $.grep(originalItemData.plugin_parent_restriction, function(r) {
9✔
885
                        // special case when PlaceholderPlugin has a parent restriction named "0"
886
                        return r !== '0';
3✔
887
                    });
888
                    const type = originalItemData.plugin_type;
9✔
889
                    // prepare variables for bound
890
                    const holderId = that.getId(placeholder.closest('.cms-dragarea'));
9✔
891
                    const holder = $('.cms-placeholder-' + holderId);
9✔
892
                    let plugin;
893

894
                    if (placeholderParent && placeholderParent.length) {
9✔
895
                        // placeholderParent is always latest, it maybe that
896
                        // isAllowed is called _before_ placeholder is moved to a child plugin
897
                        plugin = $('.cms-draggable-' + that.getId(placeholderParent.closest('.cms-draggable')));
1✔
898
                    } else {
899
                        plugin = $('.cms-draggable-' + that.getId(placeholder.closest('.cms-draggable')));
8✔
900
                    }
901

902
                    // now set the correct bounds
903
                    // istanbul ignore else
904
                    if (holder.length) {
9✔
905
                        bounds = holder.data('cms').plugin_restriction;
9✔
906
                        immediateParentType = holder.data('cms').plugin_type;
9✔
907
                    }
908
                    if (plugin.length) {
9✔
909
                        bounds = plugin.data('cms').plugin_restriction;
7✔
910
                        immediateParentType = plugin.data('cms').plugin_type;
7✔
911
                    }
912

913
                    // if restrictions is still empty, proceed
914
                    that.state = !(bounds.length && $.inArray(type, bounds) === -1);
9✔
915

916
                    // check if we have a parent restriction
917
                    if (parent_bounds.length) {
9✔
918
                        that.state = $.inArray(immediateParentType, parent_bounds) !== -1;
2✔
919
                    }
920

921
                    return that.state;
9✔
922
                }
923
            })
924
            .on('cms-structure-update', StructureBoard.actualizePlaceholders);
925
    }
926

927
    _dragRefresh() {
928
        this.ui.sortables.each((i, el) => {
11✔
929
            const element = $(el);
41✔
930

931
            if (element.data('mjsNestedSortable')) {
41!
UNCOV
932
                return;
×
933
            }
934

935
            this._drag(element);
41✔
936
        });
937
    }
938

939
    /**
940
     * @method invalidateState
941
     * @param {String} action - action to handle
942
     * @param {Object} data - data required to handle the object
943
     * @param {Object} opts
944
     * @param {Boolean} [opts.propagate=true] - should we propagate the change to other tabs or not
945
     */
946
    // eslint-disable-next-line complexity
947
    invalidateState(action, data, { propagate = true } = {}) {
50✔
948
        // eslint-disable-next-line default-case
949

950
        // By default, any edit action will result in changed content and therefore a need for an update
951
        let updateNeeded = true;
25✔
952

953
        switch (action) {
25✔
954
            case 'COPY': {
955
                this.handleCopyPlugin(data);
2✔
956
                updateNeeded = false;  // Copying, however, only changes the clipboard - no update needed
2✔
957
                break;
2✔
958
            }
959

960
             // For other actions, only refresh, if the new state cannot be determined from the data bridge
961
            case 'ADD': {
962
                updateNeeded = this.handleAddPlugin(data);
2✔
963
                break;
2✔
964
            }
965

966
            case 'EDIT': {
967
                updateNeeded = this.handleEditPlugin(data);
2✔
968
                break;
2✔
969
            }
970

971
            case 'DELETE': {
972
                updateNeeded = this.handleDeletePlugin(data);
2✔
973
                break;
2✔
974
            }
975

976
            case 'CLEAR_PLACEHOLDER': {
977
                updateNeeded = this.handleClearPlaceholder(data);
2✔
978
                break;
2✔
979
            }
980

981
            case 'PASTE':
982
            case 'MOVE': {
983
                updateNeeded = this.handleMovePlugin(data);
4✔
984
                break;
4✔
985
            }
986

987
            case 'CUT': {
988
                updateNeeded = this.handleCutPlugin(data);
2✔
989
                break;
2✔
990
            }
991

992
            case undefined:
993
            case false:
994
            case '': {
995
                CMS.API.Helpers.reloadBrowser();
1✔
996
                return;
1✔
997
            }
998

999
            default:
1000
                break;
8✔
1001
        }
1002
        Plugin._recalculatePluginPositions(action, data);
24✔
1003

1004
        if (propagate) {
24!
1005
            this._propagateInvalidatedState(action, data);
24✔
1006
        }
1007

1008
        // refresh content mode if needed
1009
        // refresh toolbar
1010
        const currentMode = CMS.settings.mode;
24✔
1011

1012
        if (currentMode === 'structure') {
24✔
1013
            this._requestcontent = null;
22✔
1014

1015
            if (this._loadedContent && updateNeeded) {
22✔
1016
                this.updateContent();
2✔
1017
                return;  // Toolbar loaded
2✔
1018
            }
1019
        } else if (updateNeeded === true) {
2!
1020
            this._requestcontent = null;
2✔
1021
            this.updateContent();
2✔
1022
            return;  // Toolbar loaded
2✔
1023

1024
        }
1025
        this._loadToolbar()
20✔
1026
            .done(newToolbar => {
1027
                CMS.API.Toolbar._refreshMarkup($(newToolbar).find('.cms-toolbar'));
1✔
1028
            })
1029
            .fail(() => Helpers.reloadBrowser());
1✔
1030
    }
1031

1032
    _propagateInvalidatedState(action, data) {
1033
        this.latestAction = [action, data];
24✔
1034

1035
        ls.set(storageKey, JSON.stringify([action, data, window.location.pathname]));
24✔
1036
    }
1037

1038
    _listenToExternalUpdates() {
1039
        if (!Helpers._isStorageSupported) {
126✔
1040
            return;
3✔
1041
        }
1042

1043
        ls.on(storageKey, this._handleExternalUpdate.bind(this));
123✔
1044
    }
1045

1046
    _handleExternalUpdate(value) {
1047
        // means localstorage was cleared while this page was open
UNCOV
1048
        if (!value) {
×
UNCOV
1049
            return;
×
1050
        }
1051

UNCOV
1052
        const [action, data, pathname] = JSON.parse(value);
×
1053

UNCOV
1054
        if (pathname !== window.location.pathname) {
×
UNCOV
1055
            return;
×
1056
        }
1057

UNCOV
1058
        if (isEqual([action, data], this.latestAction)) {
×
UNCOV
1059
            return;
×
1060
        }
1061

UNCOV
1062
        this.invalidateState(action, data, { propagate: false });
×
1063
    }
1064

1065
    updateContent() {
1066
        const loader = $('<div class="cms-content-reloading"></div>');
4✔
1067

1068
        $('.cms-structure').before(loader);
4✔
1069
        return this._requestMode('content')
4✔
1070
            .done(markup => {
1071
                // eslint-disable-next-line no-magic-numbers
1072
                loader.fadeOut(100, () => loader.remove());
2✔
1073
                this.refreshContent(markup);
2✔
1074
            })
1075
            .fail(() => loader.remove() && Helpers.reloadBrowser());
2✔
1076
    }
1077

1078
    _updateContentFromDataBridge(data) {
1079
        if (!data || !data.content || !data.content.pluginIds ||
4!
1080
            data.content.pluginIds.length < 1 || !data.content.html) {
1081
            // Non content data available in data bridge? Full content upudate needed.
1082
            return true;  // Update needed
4✔
1083
        }
1084

NEW
1085
        const existingPlugins = $(`:not(template).cms-plugin.cms-plugin-${data.content.pluginIds[0]}.cms-plugin-end`);
×
1086

NEW
1087
        if (existingPlugins.length < 1) {
×
1088
            // Plugin not found, but placeholder is known - plugin was added
NEW
1089
            const placeholder = $(`div.cms-placeholder.cms-placeholder-${data.placeholder_id}`);
×
1090

NEW
1091
            if (placeholder.length === 0) {
×
1092
                // Placeholder not found - update needed
NEW
1093
                return true;
×
1094
            }
NEW
1095
            placeholder.before(data.content.html);
×
1096
        } else {
1097
            // Add new content after existing content
NEW
1098
            existingPlugins.after(data.content.html);
×
1099
        }
1100
        // Delete previous content
1101
        // Go through all plugins and child plugins (they might not be nested)
NEW
1102
        data.content.pluginIds.forEach(id => {
×
NEW
1103
            $(`:not(template).cms-plugin.cms-plugin-${id}`).remove();
×
1104
        });
NEW
1105
        this._updateSekizai(data, 'css');
×
NEW
1106
        this._updateSekizai(data, 'js');
×
1107

NEW
1108
        this._contentChanged(data.messages);
×
NEW
1109
        return false;
×
1110
    }
1111

1112
    _updateSekizai(data, block) {
NEW
1113
        if ((data.content[block] || '').length === 0) {
×
NEW
1114
            return;
×
1115
        }
1116

1117
        // Find existing candiates, selector and cursor to write to
1118
        let current;
1119
        let selector;
1120
        let location;
1121

NEW
1122
        if (block === 'css') {
×
NEW
1123
            selector = 'link, style, meta';
×
NEW
1124
            current = document.head.querySelectorAll(selector);
×
NEW
1125
            location = document.head;
×
NEW
1126
        } else if (block === 'js') {
×
NEW
1127
            selector = 'script';
×
NEW
1128
            current = document.body.querySelectorAll(selector);
×
NEW
1129
            location = document.body;
×
1130
        } else {
NEW
1131
            return;
×
1132
        }
1133

1134
        // Parse new block, by creating the diff
1135
        // Cannot use innerHTML since this would prevent scripts to be executed.
NEW
1136
        const newElements = document.createElement('div');
×
NEW
1137
        const diff = dd.diff(newElements, `<div>${data.content[block]}</div>`);
×
1138

NEW
1139
        dd.apply(newElements, diff);
×
1140

1141
        // Collect deferred scripts to ensure firing
NEW
1142
        this.scriptReferenceCount = 0;
×
NEW
1143
        const scriptLoaded = () => {
×
NEW
1144
            if (--this.scriptReferenceCount < 1) {
×
NEW
1145
                Helpers._getWindow().dispatchEvent(new Event('load'));
×
NEW
1146
                $(Helpers._getWindow()).trigger('cms-content-refresh');
×
1147
            }
1148
        };
1149

NEW
1150
        for (const element of newElements.querySelectorAll(selector)) {
×
NEW
1151
            if (this._elementPresent(current, element)) {
×
NEW
1152
                element.remove();
×
1153
            } else {
NEW
1154
                if (element.hasAttribute('src')) {
×
NEW
1155
                    this.scriptReferenceCount++;
×
NEW
1156
                    element.onerror = element.onload = scriptLoaded;
×
1157
                }
NEW
1158
                location.appendChild(element);
×
1159
            }
1160
        }
NEW
1161
        return this.scriptReferenceCount > 0;
×
1162
    }
1163

1164
    _elementPresent(current, element) {
NEW
1165
        const markup = element.outerHTML;
×
1166

NEW
1167
        return [...current].some(el => el.outerHTML === markup);
×
1168
    }
1169

1170
    _loadToolbar() {
1171
        const placeholderIds = getPlaceholderIds(CMS._plugins).map(id => `placeholders[]=${id}`).join('&');
1✔
1172

1173
        return $.ajax({
1✔
1174
            url: Helpers.updateUrlWithPath(
1175
                `${CMS.config.request.toolbar}?` +
1176
                    placeholderIds +
1177
                    '&' +
1178
                    `obj_id=${CMS.config.request.pk}&` +
1179
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}`
1180
            )
1181
        });
1182
    }
1183

1184
    // i think this should probably be a separate class at this point that handles all the reloading
1185
    // stuff, it's a bit too much
1186
    // eslint-disable-next-line complexity
1187
    handleMovePlugin(data) {
1188
        if (data.plugin_parent) {
5✔
1189
            if (data.plugin_id) {
1!
1190
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);
1✔
1191

1192
                if (
1!
1193
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1194
                    !draggable.is('.cms-draggable-from-clipboard')
1195
                ) {
1196
                    draggable.remove();
1✔
1197
                }
1198
            }
1199

1200
            // empty the children first because replaceWith takes too much time
1201
            // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1202
            $(`.cms-draggable-${data.plugin_parent}`).html('').replaceWith(data.html);
1✔
1203
        } else {
1204
            // the one in the clipboard is first, so we need to take the second one,
1205
            // that is already visually moved into correct place
1206
            let draggable = $(`.cms-draggable-${data.plugin_id}:last`);
4✔
1207

1208
            // external update, have to move the draggable to correct place first
1209
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1210
                const pluginOrder = data.plugin_order;
2✔
1211
                const index = findIndex(
2✔
1212
                    pluginOrder,
UNCOV
1213
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
×
1214
                );
1215
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1216

1217
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
UNCOV
1218
                    draggable = draggable.clone();
×
1219
                }
1220

1221
                if (index === 0) {
2!
UNCOV
1222
                    placeholderDraggables.prepend(draggable);
×
1223
                } else if (index !== -1) {
2!
UNCOV
1224
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
×
1225
                }
1226
            }
1227

1228
            // if we _are_ in the correct placeholder we still need to check if the order is correct
1229
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
1230
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1231
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1232
                const actualPluginOrder = this.getIds(
2✔
1233
                    placeholderDraggables.find('> .cms-draggable')
1234
                );
1235

1236
                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
2!
1237
                    // so the plugin order is not correct, means it's an external update and we need to move
1238
                    const pluginOrder = data.plugin_order;
2✔
1239
                    const index = findIndex(
2✔
1240
                        pluginOrder,
1241
                        pluginId => Number(pluginId) === Number(data.plugin_id)
3✔
1242
                    );
1243

1244
                    if (index === 0) {
2✔
1245
                        placeholderDraggables.prepend(draggable);
1✔
1246
                    } else if (index !== -1) {
1!
1247
                        placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
1✔
1248
                    }
1249
                }
1250
            }
1251

1252
            if (draggable.length) {
4✔
1253
                // empty the children first because replaceWith takes too much time
1254
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1255
                draggable.html('').replaceWith(data.html);
3✔
1256
            } else if (data.target_placeholder_id) {
1!
1257
                // copy from language
1258
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
1✔
1259
            }
1260
        }
1261

1262
        StructureBoard.actualizePlaceholders();
5✔
1263
        Plugin._updateRegistry(data.plugins);
5✔
1264
        data.plugins.forEach(pluginData => {
5✔
1265
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
7✔
1266
        });
1267

1268
        StructureBoard._initializeDragItemsStates();
5✔
1269

1270
        this.ui.sortables = $('.cms-draggables');
5✔
1271
        this._dragRefresh();
5✔
1272
        return true;  // update needed
5✔
1273
    }
1274

1275
    handleCopyPlugin(data) {
1276
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1277
            CMS.API.Clipboard.modal.close();
1✔
1278
        }
1279

1280
        $('.cms-clipboard-containers').html(data.html);
2✔
1281
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1282

1283
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1284

1285
        const pluginData = [`cms-plugin-${data.plugins[0].plugin_id}`, data.plugins[0]];
2✔
1286

1287
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1288
        CMS._plugins.push(pluginData);
2✔
1289
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1290

1291
        CMS.API.Clipboard = new Clipboard();
2✔
1292

1293
        Plugin._updateClipboard();
2✔
1294

1295
        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');
2✔
1296
        const html = clipboardDraggable.parent().html();
2✔
1297

1298
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1299
        CMS.API.Clipboard._enableTriggers();
2✔
1300

1301
        this.ui.sortables = $('.cms-draggables');
2✔
1302
        this._dragRefresh();
2✔
1303
        return true;  // update needed
2✔
1304
    }
1305

1306
    handleCutPlugin(data) {
1307
        const updateNeededFromDelete = this.handleDeletePlugin(data);
1✔
1308
        const updateNeededFromCopy = this.handleCopyPlugin(data);
1✔
1309

1310
        return updateNeededFromDelete || updateNeededFromCopy;
1✔
1311
    }
1312

1313
    _extractMessages(doc) {
1314
        let messageList = doc.find('.messagelist');
6✔
1315
        let messages = messageList.find('li');
6✔
1316

1317
        if (!messageList.length || !messages.length) {
6✔
1318
            messageList = doc.find('[data-cms-messages-container]');
5✔
1319
            messages = messageList.find('[data-cms-message]');
5✔
1320
        }
1321

1322
        if (messages.length) {
6✔
1323
            messageList.remove();
3✔
1324

1325
            return compact(
3✔
1326
                messages.toArray().map(el => {
1327
                    const msgEl = $(el);
7✔
1328
                    const message = $(el).text().trim();
7✔
1329

1330
                    if (message) {
7✔
1331
                        return {
6✔
1332
                            message,
1333
                            error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
10✔
1334
                        };
1335
                    }
1336
                })
1337
            );
1338
        }
1339

1340
        return [];
3✔
1341
    }
1342

1343
    refreshContent(contentMarkup) {
1344
        this._requestcontent = null;
3✔
1345
        if (!this._loadedStructure) {
3!
1346
            this._requeststructure = null;
3✔
1347
        }
1348
        const newDoc = new DOMParser().parseFromString(contentMarkup, 'text/html');
3✔
1349

1350
        const structureScrollTop = $('.cms-structure-content').scrollTop();
3✔
1351

1352
        const toolbar = $('#cms-top, [data-cms]').detach();
3✔
1353
        const newToolbar = $(newDoc).find('.cms-toolbar').clone();
3✔
1354

1355
        $(newDoc).find('#cms-top, [data-cms]').remove();
3✔
1356

1357
        const messages = this._extractMessages($(newDoc));
3✔
1358

1359
        if (messages.length) {
3✔
1360
            setTimeout(() =>
1✔
1361
                messages.forEach(message => {
1✔
1362
                    CMS.API.Messages.open(message);
1✔
1363
                })
1364
            );
1365
        }
1366
        const headDiff = dd.diff(document.head, nodeToObj(newDoc.head));
3✔
1367

1368
        StructureBoard._replaceBodyWithHTML(newDoc.body);
3✔
1369
        dd.apply(document.head, headDiff);
3✔
1370

1371
        toolbar.prependTo(document.body);
3✔
1372
        CMS.API.Toolbar._refreshMarkup(newToolbar);
3✔
1373

1374
        $('.cms-structure-content').scrollTop(structureScrollTop);
3✔
1375
        this._loadedContent = true;
3✔
1376
        this._contentChanged();
3✔
1377
    }
1378

1379
    _contentChanged(messages) {
1380
        Plugin._refreshPlugins();
3✔
1381

1382
        Helpers._getWindow().dispatchEvent(new Event('load'));
3✔
1383
        $(Helpers._getWindow()).trigger('cms-content-refresh');
3✔
1384
        if (messages) {
3!
NEW
1385
            CMS.API.Messages.close();
×
NEW
1386
            if (messages.length) {
×
NEW
1387
                CMS.API.Messages.open({
×
NEW
1388
                    message: messages.map(message => `<p>${message.message}</p>`).join(''),
×
NEW
1389
                    error: messages.some(message => message.level === 'error')
×
1390
                });
1391
            }
1392
        }
1393
    }
1394

1395
    handleAddPlugin(data) {
1396
        if (data.plugin_parent) {
2✔
1397
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1398
        } else {
1399
            // the one in the clipboard is first
1400
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
1✔
1401
        }
1402

1403
        StructureBoard.actualizePlaceholders();
2✔
1404
        Plugin._updateRegistry(data.structure.plugins);
2✔
1405
        data.structure.plugins.forEach(pluginData => {
2✔
1406
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
3✔
1407
        });
1408

1409
        this.ui.sortables = $('.cms-draggables');
2✔
1410
        this._dragRefresh();
2✔
1411
        return this._updateContentFromDataBridge(data);
2✔
1412
    }
1413

1414
    handleEditPlugin(data) {
1415
        if (data.plugin_parent) {
2✔
1416
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1417
        } else {
1418
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
1✔
1419
        }
1420

1421
        Plugin._updateRegistry(data.structure.plugins);
2✔
1422

1423
        data.structure.plugins.forEach(pluginData => {
2✔
1424
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
2✔
1425
        });
1426

1427
        this.ui.sortables = $('.cms-draggables');
2✔
1428
        this._dragRefresh();
2✔
1429
        return this._updateContentFromDataBridge(data);
2✔
1430
    }
1431

1432
    handleDeletePlugin(data) {
1433
        let deletedPluginIds = [data.plugin_id];
2✔
1434
        const draggable = $('.cms-draggable-' + data.plugin_id);
2✔
1435
        const children = draggable.find('.cms-draggable');
2✔
1436
        let parent = draggable.parent().closest('.cms-draggable');
2✔
1437

1438
        if (!parent.length) {
2✔
1439
            parent = draggable.closest('.cms-dragarea');
1✔
1440
        }
1441

1442
        if (children.length) {
2✔
1443
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
1✔
1444
        }
1445

1446
        draggable.remove();
2✔
1447

1448
        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
2✔
1449
        StructureBoard.actualizePlaceholders();
2✔
1450
        deletedPluginIds.forEach(function(pluginId) {
2✔
1451
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
5✔
1452
            remove(
3✔
1453
                CMS._instances,
1454
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
5✔
1455
            );
1456
        });
1457
        return true;
2✔
1458
    }
1459

1460
    handleClearPlaceholder(data) {
1461
        const deletedIds = CMS._instances
1✔
1462
            .filter(instance => {
1463
                if (
3✔
1464
                    instance.options.plugin_id &&
6✔
1465
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
1466
                ) {
1467
                    return true;
2✔
1468
                }
1469
            })
1470
            .map(instance => instance.options.plugin_id);
2✔
1471

1472
        deletedIds.forEach(id => {
1✔
1473
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
5✔
1474
            remove(
2✔
1475
                CMS._instances,
1476
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
5✔
1477
            );
1478

1479
            $(`.cms-draggable-${id}`).remove();
2✔
1480
        });
1481

1482
        StructureBoard.actualizePlaceholders();
1✔
1483
        return true;
1✔
1484
    }
1485

1486
    /**
1487
     * Similar to CMS.Plugin populates globally required
1488
     * variables, that only need querying once, e.g. placeholders.
1489
     *
1490
     * @method _initializeGlobalHandlers
1491
     * @static
1492
     * @private
1493
     */
1494
    static _initializeGlobalHandlers() {
1495
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
77✔
1496
    }
1497

1498
    /**
1499
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
1500
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
1501
     *
1502
     * @function actualizePlaceholders
1503
     * @private
1504
     */
1505
    static actualizePlaceholders() {
1506
        placeholders.each(function() {
156✔
1507
            const placeholder = $(this);
465✔
1508
            const copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');
465✔
1509

1510
            if (
465✔
1511
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
1512
                    .length
1513
            ) {
1514
                placeholder.removeClass('cms-dragarea-empty');
155✔
1515
                copyAll.removeClass('cms-submenu-item-disabled');
155✔
1516
                copyAll.find('> a').removeAttr('aria-disabled');
155✔
1517
            } else {
1518
                placeholder.addClass('cms-dragarea-empty');
310✔
1519
                copyAll.addClass('cms-submenu-item-disabled');
310✔
1520
                copyAll.find('> a').attr('aria-disabled', 'true');
310✔
1521
            }
1522
        });
1523

1524
        const addPluginPlaceholder = $('.cms-dragarea .cms-add-plugin-placeholder');
156✔
1525

1526
        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
156!
UNCOV
1527
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
×
1528
        }
1529
    }
1530

1531
    /**
1532
     * actualizePluginCollapseStatus
1533
     *
1534
     * @public
1535
     * @param {String} pluginId open the plugin if it should be open
1536
     */
1537
    static actualizePluginCollapseStatus(pluginId) {
1538
        const el = $(`.cms-draggable-${pluginId}`);
1✔
1539
        const open = find(CMS.settings.states, openPluginId => Number(openPluginId) === Number(pluginId));
1✔
1540

1541
        // only add this class to elements which have a draggable area
1542
        // istanbul ignore else
1543
        if (open && el.find('> .cms-draggables').length) {
1✔
1544
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1✔
1545
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1✔
1546
        }
1547
    }
1548

1549
    /**
1550
     * @function actualizePluginsCollapsibleStatus
1551
     * @private
1552
     * @param {jQuery} els lists of plugins (.cms-draggables)
1553
     */
1554
    static actualizePluginsCollapsibleStatus(els) {
1555
        els.each(function() {
9✔
1556
            const childList = $(this);
14✔
1557
            const pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');
14✔
1558

1559
            if (childList.children().length) {
14✔
1560
                pluginDragItem.addClass('cms-dragitem-collapsable');
10✔
1561
                if (childList.children().is(':visible')) {
10!
1562
                    pluginDragItem.addClass('cms-dragitem-expanded');
10✔
1563
                }
1564
            } else {
1565
                pluginDragItem.removeClass('cms-dragitem-collapsable');
4✔
1566
            }
1567
        });
1568
    }
1569

1570
    static _replaceBodyWithHTML(body, forceFullReplace = true) {
×
NEW
1571
        if (forceFullReplace) {
×
1572
            // Resets all events etc.
NEW
1573
            document.body.innerHTML = body.innerHTML;
×
1574
        } else {
1575
            // Mostly small changes to the body, so we can just diff and apply
NEW
1576
            const bodyDiff = dd.diff(document.body, body);
×
1577

NEW
1578
            dd.apply(document.body, bodyDiff);
×
1579
        }
1580
    }
1581

1582
    highlightPluginFromUrl() {
1583
        const hash = window.location.hash;
116✔
1584
        const regex = /cms-plugin-(\d+)/;
116✔
1585

1586
        if (!hash || !hash.match(regex)) {
116!
1587
            return;
116✔
1588
        }
1589

UNCOV
1590
        const pluginId = regex.exec(hash)[1];
×
1591

UNCOV
1592
        if (this._loadedContent) {
×
UNCOV
1593
            Plugin._highlightPluginContent(pluginId, {
×
1594
                seeThrough: true,
1595
                prominent: true,
1596
                delay: 3000
1597
            });
1598
        }
1599
    }
1600

1601
    /**
1602
     * Get's plugins data from markup
1603
     *
1604
     * @method _getPluginDataFromMarkup
1605
     * @private
1606
     * @param {String} markup
1607
     * @param {Array<Number | String>} pluginIds
1608
     * @returns {Array<[String, Object]>}
1609
     */
1610
    static _getPluginDataFromMarkup(markup, pluginIds) {
1611
        return compact(
9✔
1612
            pluginIds.map(pluginId => {
1613
                // oh boy
1614
                const regex = new RegExp(`CMS._plugins.push\\((\\["cms\-plugin\-${pluginId}",[\\s\\S]*?\\])\\)`, 'g');
15✔
1615
                const matches = regex.exec(markup);
15✔
1616
                let settings;
1617

1618
                if (matches) {
15✔
1619
                    try {
5✔
1620
                        settings = JSON.parse(matches[1]);
5✔
1621
                    } catch (e) {
1622
                        settings = false;
2✔
1623
                    }
1624
                } else {
1625
                    settings = false;
10✔
1626
                }
1627

1628
                return settings;
15✔
1629
            })
1630
        );
1631
    }
1632

1633
}
1634

1635
/**
1636
 * Initializes the collapsed/expanded states of dragitems in structureboard.
1637
 *
1638
 * @method _initializeDragItemsStates
1639
 * @static
1640
 * @private
1641
 */
1642
// istanbul ignore next
1643
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
1644
    // removing duplicate entries
1645
    'use strict';
1646

1647
    const states = CMS.settings.states || [];
1648
    const sortedArr = states.sort();
1649
    const filteredArray = [];
1650

1651
    for (let i = 0; i < sortedArr.length; i++) {
1652
        if (sortedArr[i] !== sortedArr[i + 1]) {
1653
            filteredArray.push(sortedArr[i]);
1654
        }
1655
    }
1656
    CMS.settings.states = filteredArray;
1657

1658
    // loop through the items
1659
    $.each(CMS.settings.states, function(index, id) {
1660
        const el = $('.cms-draggable-' + id);
1661

1662
        // only add this class to elements which have immediate children
1663
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
1664
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1665
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1666
        }
1667
    });
1668
};
1669

1670
// shorthand for jQuery(document).ready();
1671
$(StructureBoard._initializeGlobalHandlers);
1✔
1672

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

© 2025 Coveralls, Inc