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

divio / django-cms / #29483

02 Mar 2025 11:36AM UTC coverage: 75.059%. Remained the same
#29483

push

travis-ci

web-flow
Merge d82a92823 into e331372d9

1072 of 1625 branches covered (65.97%)

4 of 4 new or added lines in 1 file covered. (100.0%)

3 existing lines in 1 file now uncovered.

2564 of 3416 relevant lines covered (75.06%)

26.3 hits per line

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

86.06
/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();
130✔
58

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

69
        dd = new DiffDOM();
130✔
70

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

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

82
        setTimeout(() => this.highlightPluginFromUrl(), 0);
130✔
83
        this._listenToExternalUpdates();
130✔
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');
130✔
94
        const toolbar = $('.cms-toolbar');
130✔
95

96
        this.ui = {
130✔
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]);
130✔
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;
130✔
126

127
        // cancel if there is no structure / content switcher
128
        if (!this.ui.toolbarModeSwitcher.length) {
130✔
129
            return false;
23✔
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', () => {
130✔
184
            if (!this._loadedContent || CMS.config.mode !== 'draft') {
8,853!
185
                return;
8,853✔
186
            }
187
            const width = this.ui.window[0].innerWidth;
×
188
            const BREAKPOINT = 1024;
×
189

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

194
            if (width <= BREAKPOINT && this.condensed) {
×
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;
130✔
209
        let cmdPressed;
210

211
        $(Helpers._getWindow())
130✔
212
            .on(this.keyUpAndDown, e => {
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
                ) {
220
                    cmdPressed = true;
×
221
                }
222
                if (e.type === 'keyup') {
×
223
                    cmdPressed = false;
×
224
                }
225
            })
226
            .on('blur', () => {
227
                cmdPressed = false;
×
228
            });
229

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

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

239
            if (cmdPressed && e.type === 'click') {
4!
240
                // control the behaviour when ctrl/cmd is pressed
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 (
130✔
255
            this.ui.toolbarModeSwitcher.length &&
237✔
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')
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✔
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() {
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.document.dispatchEvent(new Event('DOMContentLoaded'));
536
                    window.dispatchEvent(new Event('load'));
537
                });
538

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

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

548
                that._loadedContent = true;
1✔
549
            })
550
            .fail(function() {
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!
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) {
130✔
1040
            return;
3✔
1041
        }
1042

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

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

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

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

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

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) {  // eslint-disable-line complexity
1079
        if (!data || !data.content || !data.content.pluginIds ||
15✔
1080
            data.content.pluginIds.length < 1 || data.content.html === undefined) {
1081
            // No content data available in data bridge? Full content upudate needed.
1082
            return true;  // Update needed
12✔
1083
        }
1084
        if (data.source_placeholder_id && !CMS._instances.some(
3✔
1085
                instance => instance.options.type === 'plugin' &&
×
1086
                instance.options.placeholder_id == data.source_placeholder_id  // eslint-disable-line eqeqeq
1087
        )) {
1088
            // If last plugin was moved from a placeholder, the placeholder needs to be updated
1089
            return true;  // Update needed
1✔
1090
        }
1091

1092
        let nextEl = $(`:not(template).cms-plugin.cms-plugin-${data.content.pluginIds[0]}.cms-plugin-start`);
2✔
1093

1094
        if (nextEl.length < 1 || data.insert) {
2!
1095
            // Plugin not found, but placeholder is known - plugin was added
1096
            const placeholder_id = data.content.placeholder_id;
2✔
1097
            const position = data.content.position;
2✔
1098

1099
            nextEl = this._findNextElement(position, placeholder_id, data.content.pluginIds);
2✔
1100
        }
1101
        if (nextEl.length === 0) {
2!
1102
            // Placeholder not found - update needed
1103
            return true;
×
1104
        }
1105
        nextEl.before(data.content.html);
2✔
1106

1107
        // Delete previous content
1108
        // Go through all plugins and child plugins (they might not be nested)
1109
        data.content.pluginIds.forEach(id => {
2✔
1110
            $(`:not(template).cms-plugin.cms-plugin-${id}`).remove();
3✔
1111
            // Plugin data is updated through the data bridge - script elements can be removed
1112
            $(`script[data-cms-plugin]#cms-plugin-${id}`).remove();
3✔
1113
        });
1114
        this._updateSekizai(data, 'css');
2✔
1115
        if (!this._updateSekizai(data, 'js')) {
2!
1116
            // No scripts need to be loaded - content update is done
1117
            StructureBoard._triggerRefreshEvents();
2✔
1118
        }
1119

1120
        this._contentChanged(data.messages);
2✔
1121
        return false;
2✔
1122
    }
1123

1124
    _findNextElement(position, placeholder_id, excludedPlugins) {
1125
        let nextEl = $(`div.cms-placeholder.cms-placeholder-${placeholder_id}`);
×
1126
        const nextPlugins = CMS._instances.filter(instance =>
×
1127
            instance.options.type === 'plugin' &&
×
1128
            instance.options.placeholder_id == placeholder_id &&  // eslint-disable-line eqeqeq
1129
            instance.options.position >= position &&
1130
            !excludedPlugins.includes(1 * instance.options.plugin_id));
1131

1132
        if (nextPlugins.length > 0) {
×
1133
            // Plugins found with higher position, get the one with lowest position
1134
            const nextPluginId = nextPlugins.reduce((acc, instance) => {
×
1135
                return instance.options.position < acc.options.position ? instance : acc;
×
1136
            }, nextPlugins[0]).options.plugin_id;
1137

1138
            nextEl = $(`.cms-plugin.cms-plugin-${nextPluginId}.cms-plugin-start`);
×
1139
        }
1140
        return nextEl;
×
1141
    }
1142

1143
    _updateSekizai(data, block) {
1144
        if ((data.content[block] || '').length === 0) {
×
1145
            return;
×
1146
        }
1147

1148
        // Find existing candiates, selector and cursor to write to
1149
        let current;
1150
        let selector;
1151
        let location;
1152

1153
        if (block === 'css') {
×
1154
            selector = 'link, style, meta';
×
1155
            current = document.head.querySelectorAll(selector);
×
1156
            location = document.head;
×
1157
        } else if (block === 'js') {
×
1158
            selector = 'script';
×
1159
            current = document.body.querySelectorAll(selector);
×
1160
            location = document.body;
×
1161
        } else {
1162
            return;
×
1163
        }
1164

1165
        // Parse new block, by creating the diff
1166
        // Cannot use innerHTML since this would prevent scripts to be executed.
1167
        const newElements = document.createElement('div');
×
1168
        const diff = dd.diff(newElements, `<div>${data.content[block]}</div>`);
×
1169

1170
        dd.apply(newElements, diff);
×
1171

1172
        // Collect deferred scripts to ensure firing
1173
        this.scriptReferenceCount = 0;
×
1174

1175
        for (const element of newElements.querySelectorAll(selector)) {
×
1176
            if (StructureBoard._elementPresent(current, element)) {
×
1177
                element.remove();
×
1178
            } else {
1179
                if (element.hasAttribute('src')) {
×
1180
                    this.scriptReferenceCount++;
×
1181
                    element.onload = element.onerror = this._scriptLoaded.bind(this);
×
1182
                }
1183
                location.appendChild(element);
×
1184
            }
1185
        }
1186
        return this.scriptReferenceCount > 0;
×
1187
    }
1188

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

1192
        return $.ajax({
1✔
1193
            url: Helpers.updateUrlWithPath(
1194
                `${CMS.config.request.toolbar}?` +
1195
                    placeholderIds +
1196
                    '&' +
1197
                    `obj_id=${CMS.config.request.pk}&` +
1198
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}`
1199
            )
1200
        });
1201
    }
1202

1203
    // i think this should probably be a separate class at this point that handles all the reloading
1204
    // stuff, it's a bit too much
1205
    // eslint-disable-next-line complexity
1206
    handleMovePlugin(data) {
1207
        if (data.plugin_parent) {
5✔
1208
            if (data.plugin_id) {
1!
1209
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);
1✔
1210

1211
                if (
1!
1212
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1213
                    !draggable.is('.cms-draggable-from-clipboard')
1214
                ) {
1215
                    draggable.remove();
1✔
1216
                }
1217
            }
1218

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

1227
            // external update, have to move the draggable to correct place first
1228
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1229
                const pluginOrder = data.plugin_order;
2✔
1230
                const index = findIndex(
2✔
1231
                    pluginOrder,
1232
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
×
1233
                );
1234
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1235

1236
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
1237
                    draggable = draggable.clone();
×
1238
                }
1239

1240
                if (index === 0) {
2!
1241
                    placeholderDraggables.prepend(draggable);
×
1242
                } else if (index !== -1) {
2!
1243
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
×
1244
                }
1245
            }
1246

1247
            // if we _are_ in the correct placeholder we still need to check if the order is correct
1248
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
1249
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1250
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1251
                const actualPluginOrder = this.getIds(
2✔
1252
                    placeholderDraggables.find('> .cms-draggable')
1253
                );
1254

1255
                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
2!
1256
                    // so the plugin order is not correct, means it's an external update and we need to move
1257
                    const pluginOrder = data.plugin_order;
2✔
1258
                    const index = findIndex(
2✔
1259
                        pluginOrder,
1260
                        pluginId => Number(pluginId) === Number(data.plugin_id)
3✔
1261
                    );
1262

1263
                    if (index === 0) {
2✔
1264
                        placeholderDraggables.prepend(draggable);
1✔
1265
                    } else if (index !== -1) {
1!
1266
                        placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
1✔
1267
                    }
1268
                }
1269
            }
1270

1271
            if (draggable.length) {
4✔
1272
                // empty the children first because replaceWith takes too much time
1273
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1274
                draggable.html('').replaceWith(data.html);
3✔
1275
            } else if (data.target_placeholder_id) {
1!
1276
                // copy from language
1277
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
1✔
1278
            }
1279
        }
1280

1281
        StructureBoard.actualizePlaceholders();
5✔
1282
        Plugin._updateRegistry(data.plugins);
5✔
1283
        data.plugins.forEach(pluginData => {
5✔
1284
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
7✔
1285
        });
1286

1287
        StructureBoard._initializeDragItemsStates();
5✔
1288

1289
        this.ui.sortables = $('.cms-draggables');
5✔
1290
        this._dragRefresh();
5✔
1291
        return this._updateContentFromDataBridge(data);
5✔
1292
    }
1293

1294
    handleCopyPlugin(data) {
1295
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1296
            CMS.API.Clipboard.modal.close();
1✔
1297
        }
1298

1299
        $('.cms-clipboard-containers').html(data.html);
2✔
1300
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1301

1302
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1303

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

1306
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1307
        CMS._plugins.push(pluginData);
2✔
1308
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1309

1310
        CMS.API.Clipboard = new Clipboard();
2✔
1311

1312
        Plugin._updateClipboard();
2✔
1313

1314
        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');
2✔
1315
        const html = clipboardDraggable.parent().html();
2✔
1316

1317
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1318
        CMS.API.Clipboard._enableTriggers();
2✔
1319

1320
        this.ui.sortables = $('.cms-draggables');
2✔
1321
        this._dragRefresh();
2✔
1322
        return true;  // update needed
2✔
1323
    }
1324

1325
    handleCutPlugin(data) {
1326
        const updateNeededFromDelete = this.handleDeletePlugin(data);
1✔
1327

1328
        this.handleCopyPlugin(data);
1✔
1329
        return updateNeededFromDelete;
1✔
1330
    }
1331

1332
    _extractMessages(doc) {
1333
        let messageList = doc.find('.messagelist');
6✔
1334
        let messages = messageList.find('li');
6✔
1335

1336
        if (!messageList.length || !messages.length) {
6✔
1337
            messageList = doc.find('[data-cms-messages-container]');
5✔
1338
            messages = messageList.find('[data-cms-message]');
5✔
1339
        }
1340

1341
        if (messages.length) {
6✔
1342
            messageList.remove();
3✔
1343

1344
            return compact(
3✔
1345
                messages.toArray().map(el => {
1346
                    const msgEl = $(el);
7✔
1347
                    const message = $(el).text().trim();
7✔
1348

1349
                    if (message) {
7✔
1350
                        return {
6✔
1351
                            message,
1352
                            error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
10✔
1353
                        };
1354
                    }
1355
                })
1356
            );
1357
        }
1358

1359
        return [];
3✔
1360
    }
1361

1362
    refreshContent(contentMarkup) {
1363
        this._requestcontent = null;
3✔
1364
        if (!this._loadedStructure) {
3!
1365
            this._requeststructure = null;
3✔
1366
        }
1367
        const newDoc = new DOMParser().parseFromString(contentMarkup, 'text/html');
3✔
1368

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

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

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

1376
        const messages = this._extractMessages($(newDoc));
3✔
1377

1378
        if (messages.length) {
3✔
1379
            setTimeout(() =>
1✔
1380
                messages.forEach(message => {
1✔
1381
                    CMS.API.Messages.open(message);
1✔
1382
                })
1383
            );
1384
        }
1385
        const headDiff = dd.diff(document.head, nodeToObj(newDoc.head));
3✔
1386

1387
        this._replaceBodyWithHTML(newDoc.body);
3✔
1388
        dd.apply(document.head, headDiff);
3✔
1389

1390
        toolbar.prependTo(document.body);
3✔
1391
        CMS.API.Toolbar._refreshMarkup(newToolbar);
3✔
1392

1393
        $('.cms-structure-content').scrollTop(structureScrollTop);
3✔
1394
        this._loadedContent = true;
3✔
1395
        this._contentChanged();
3✔
1396
    }
1397

1398
    _contentChanged(messages) {
1399
        Plugin._refreshPlugins();
3✔
1400
        if (messages) {
3!
1401
            CMS.API.Messages.close();
×
1402
            if (messages.length) {
×
1403
                CMS.API.Messages.open({
×
1404
                    message: messages.map(message => `<p>${message.message}</p>`).join(''),
×
1405
                    error: messages.some(message => message.level === 'error')
×
1406
                });
1407
            }
1408
        }
1409
    }
1410

1411
    handleAddPlugin(data) {
1412
        if (data.plugin_parent) {
2✔
1413
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1414
        } else {
1415
            // the one in the clipboard is first
1416
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
1✔
1417
        }
1418

1419
        StructureBoard.actualizePlaceholders();
2✔
1420
        Plugin._updateRegistry(data.structure.plugins);
2✔
1421
        data.structure.plugins.forEach(pluginData => {
2✔
1422
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
3✔
1423
        });
1424

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

1430
    handleEditPlugin(data) {
1431
        if (data.plugin_parent) {
2✔
1432
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1433
        } else {
1434
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
1✔
1435
        }
1436

1437
        Plugin._updateRegistry(data.structure.plugins);
2✔
1438

1439
        data.structure.plugins.forEach(pluginData => {
2✔
1440
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
2✔
1441
        });
1442

1443
        this.ui.sortables = $('.cms-draggables');
2✔
1444
        this._dragRefresh();
2✔
1445
        return this._updateContentFromDataBridge(data.structure);
2✔
1446
    }
1447

1448
    handleDeletePlugin(data) {
1449
        const placeholder_id = CMS._instances.find(
2✔
1450
            plugin => plugin.options.plugin_id === data.plugin_id
2✔
1451
        ).options.placeholder_id;
1452
        const draggable = $('.cms-draggable-' + data.plugin_id);
2✔
1453
        const children = draggable.find('.cms-draggable');
2✔
1454
        let deletedPluginIds = [data.plugin_id];
2✔
1455
        let parent = draggable.parent().closest('.cms-draggable');
2✔
1456

1457
        if (!parent.length) {
2✔
1458
            parent = draggable.closest('.cms-dragarea');
1✔
1459
        }
1460

1461
        if (children.length) {
2✔
1462
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
1✔
1463
        }
1464

1465
        draggable.remove();
2✔
1466

1467
        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
2✔
1468
        StructureBoard.actualizePlaceholders();
2✔
1469
        deletedPluginIds.forEach(function(pluginId) {
2✔
1470
            $(`.cms-plugin.cms-plugin-${pluginId}`).remove();  // Remove from content
3✔
1471
            $(`script[data-cms-plugin]#cms-plugin-${pluginId}`).remove();  // Remove script elements
3✔
1472
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
5✔
1473
            remove(
3✔
1474
                CMS._instances,
1475
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
5✔
1476
            );
1477
        });
1478
        const lastPluginDeleted = CMS._instances.find(
2✔
1479
            plugin => plugin.options.placeholder_id == placeholder_id  // eslint-disable-line eqeqeq
1✔
1480
        ) === undefined;
1481

1482
        return lastPluginDeleted;
2✔
1483
    }
1484

1485
    handleClearPlaceholder(data) {
1486
        const deletedIds = CMS._instances
1✔
1487
            .filter(instance => {
1488
                if (
3✔
1489
                    instance.options.plugin_id &&
6✔
1490
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
1491
                ) {
1492
                    return true;
2✔
1493
                }
1494
            })
1495
            .map(instance => instance.options.plugin_id);
2✔
1496

1497
        deletedIds.forEach(id => {
1✔
1498
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
5✔
1499
            remove(
2✔
1500
                CMS._instances,
1501
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
5✔
1502
            );
1503

1504
            $(`.cms-draggable-${id}`).remove();
2✔
1505
        });
1506

1507
        StructureBoard.actualizePlaceholders();
1✔
1508
        return true;
1✔
1509
    }
1510

1511
    /**
1512
     * Similar to CMS.Plugin populates globally required
1513
     * variables, that only need querying once, e.g. placeholders.
1514
     *
1515
     * @method _initializeGlobalHandlers
1516
     * @static
1517
     * @private
1518
     */
1519
    static _initializeGlobalHandlers() {
1520
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
77✔
1521
    }
1522

1523
    /**
1524
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
1525
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
1526
     *
1527
     * @function actualizePlaceholders
1528
     * @private
1529
     */
1530
    static actualizePlaceholders() {
1531
        placeholders.each(function() {
160✔
1532
            const placeholder = $(this);
477✔
1533
            const copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');
477✔
1534

1535
            if (
477✔
1536
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
1537
                    .length
1538
            ) {
1539
                placeholder.removeClass('cms-dragarea-empty');
159✔
1540
                copyAll.removeClass('cms-submenu-item-disabled');
159✔
1541
                copyAll.find('> a').removeAttr('aria-disabled');
159✔
1542
            } else {
1543
                placeholder.addClass('cms-dragarea-empty');
318✔
1544
                copyAll.addClass('cms-submenu-item-disabled');
318✔
1545
                copyAll.find('> a').attr('aria-disabled', 'true');
318✔
1546
            }
1547
        });
1548

1549
        const addPluginPlaceholder = $('.cms-dragarea .cms-add-plugin-placeholder');
160✔
1550

1551
        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
160!
1552
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
×
1553
        }
1554
    }
1555

1556
    /**
1557
     * actualizePluginCollapseStatus
1558
     *
1559
     * @public
1560
     * @param {String} pluginId open the plugin if it should be open
1561
     */
1562
    static actualizePluginCollapseStatus(pluginId) {
1563
        const el = $(`.cms-draggable-${pluginId}`);
1✔
1564
        const open = find(CMS.settings.states, openPluginId => Number(openPluginId) === Number(pluginId));
1✔
1565

1566
        // only add this class to elements which have a draggable area
1567
        // istanbul ignore else
1568
        if (open && el.find('> .cms-draggables').length) {
1✔
1569
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1✔
1570
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1✔
1571
        }
1572
    }
1573

1574
    /**
1575
     * @function actualizePluginsCollapsibleStatus
1576
     * @private
1577
     * @param {jQuery} els lists of plugins (.cms-draggables)
1578
     */
1579
    static actualizePluginsCollapsibleStatus(els) {
1580
        els.each(function() {
9✔
1581
            const childList = $(this);
14✔
1582
            const pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');
14✔
1583

1584
            if (childList.children().length) {
14✔
1585
                pluginDragItem.addClass('cms-dragitem-collapsable');
10✔
1586
                if (childList.children().is(':visible')) {
10!
1587
                    pluginDragItem.addClass('cms-dragitem-expanded');
10✔
1588
                }
1589
            } else {
1590
                pluginDragItem.removeClass('cms-dragitem-collapsable');
4✔
1591
            }
1592
        });
1593
    }
1594

1595
    /**
1596
     * Replaces the current document body with the provided HTML content.
1597
     *
1598
     * This method removes all existing script elements from the document body,
1599
     * replaces the body content with the new HTML, and then re-inserts new script
1600
     * elements to ensure they are executed.
1601
     *
1602
     * @param {HTMLElement} body - The new HTML content to replace the current body.
1603
     *
1604
     * @private
1605
     */
1606
    _replaceBodyWithHTML(body) {
1607
        // Remove (i.e. detach) old scripts
1608
        const oldScripts = document.body.querySelectorAll('script:not([type="application/json"])');
×
1609

1610
        oldScripts.forEach(script => script.remove());
×
1611

1612
        // Replace the body content
1613
        document.body.innerHTML = body.innerHTML;
×
1614

1615
        // Process new scripts in a dedicated helper
1616
        const newScripts = document.body.querySelectorAll('script:not([type="application/json"])');
×
1617

1618
        this._processNewScripts(newScripts, oldScripts);
×
1619

1620
        if (this.scriptReferenceCount === 0) {
×
1621
            StructureBoard._triggerRefreshEvents();
×
1622
        }
1623
    }
1624

1625
    /**
1626
     * Processes new script elements by comparing them with old script elements.
1627
     * If a new script is not present in the old scripts, it rewrites the script to the DOM to force execution.
1628
     *
1629
     * @param {NodeList} newScripts - A list of new script elements to be processed.
1630
     * @param {NodeList} oldScripts - A list of old script elements to compare against.
1631
     * @private
1632
     */
1633
    _processNewScripts(newScripts, oldScripts) {
1634
        newScripts.forEach(script => {
×
1635
            if (!StructureBoard._elementPresent(oldScripts, script)) {
×
1636
                // Rewrite script to DOM to force execution
1637
                const newScript = document.createElement('script');
×
1638

1639
                // Copy attributes
1640
                Array.from(script.attributes).forEach(attr => {
×
1641
                    newScript.setAttribute(attr.name, attr.value);
×
1642
                });
1643
                if (script.src) {
×
1644
                    // Needs to be loaded from a server
1645
                    this.scriptReferenceCount++;
×
1646
                    newScript.onload = newScript.onerror = this._scriptLoaded.bind(this);
×
1647
                } else {
1648
                    // Inline script
1649
                    newScript.textContent = script.textContent;
×
1650
                }
1651
                script.parentNode.insertBefore(newScript, script.nextSibling);
×
1652
                script.remove();
×
1653
            }
1654
        });
1655
    }
1656

1657
    /**
1658
     * Checks if a given element is present in the current set of elements.
1659
     *
1660
     * @param {NodeList} current - The current set of elements to check against.
1661
     * @param {Element} element - The element to check for presence.
1662
     * @returns {boolean} - Returns true if the element is present in the current set, otherwise false.
1663
     * @private
1664
     */
1665
    static _elementPresent(current, element) {
1666
        const markup = element.outerHTML;
×
1667

1668
        return [...current].some(el => el.outerHTML === markup);
×
1669
    }
1670

1671
    /**
1672
     * Handles the event when a script is loaded.
1673
     * Decrements the script reference count and, if it reaches zero,
1674
     * dispatches a 'load' event on the window and triggers a 'cms-content-refresh' event.
1675
     *
1676
     * @private
1677
     */
1678
    _scriptLoaded() {
1679
        if (--this.scriptReferenceCount < 1) {
×
1680
            StructureBoard._triggerRefreshEvents();
×
1681
        }
1682
    }
1683

1684
    /**
1685
     * Triggers refresh events on the window and document.
1686
     *
1687
     * This method dispatches the 'DOMContentLoaded' event on the document,
1688
     * the 'load' event on the window, and triggers the 'cms-content-refresh'
1689
     * event using jQuery on the window. The events are dispatched asynchronously
1690
     * to ensure that the current execution context is completed before the events
1691
     * are triggered.
1692
     *
1693
     * @private
1694
     * @static
1695
     */
1696
    static _triggerRefreshEvents() {
1697
        setTimeout(() => {
2✔
1698
            Helpers._getWindow().document.dispatchEvent(new Event('DOMContentLoaded'));
2✔
1699
            Helpers._getWindow().dispatchEvent(new Event('load'));
2✔
1700
            $(Helpers._getWindow()).trigger('cms-content-refresh');
2✔
1701
        }, 0);
1702
    }
1703

1704
    highlightPluginFromUrl() {
1705
        const hash = window.location.hash;
120✔
1706
        const regex = /cms-plugin-(\d+)/;
120✔
1707

1708
        if (!hash || !hash.match(regex)) {
120!
1709
            return;
120✔
1710
        }
1711

UNCOV
1712
        const pluginId = regex.exec(hash)[1];
×
1713

UNCOV
1714
        if (this._loadedContent) {
×
UNCOV
1715
            Plugin._highlightPluginContent(pluginId, {
×
1716
                seeThrough: true,
1717
                prominent: true,
1718
                delay: 3000
1719
            });
1720
        }
1721
    }
1722

1723
    /**
1724
     * Get's plugins data from markup
1725
     *
1726
     * @method _getPluginDataFromMarkup
1727
     * @private
1728
     * @param {String} markup
1729
     * @param {Array<Number | String>} pluginIds
1730
     * @returns {Array<[String, Object]>}
1731
     */
1732
    static _getPluginDataFromMarkup(markup, pluginIds) {
1733
        return compact(
9✔
1734
            pluginIds.map(pluginId => {
1735
                // oh boy
1736
                const regex = new RegExp(`CMS._plugins.push\\((\\["cms\-plugin\-${pluginId}",[\\s\\S]*?\\])\\)`, 'g');
15✔
1737
                const matches = regex.exec(markup);
15✔
1738
                let settings;
1739

1740
                if (matches) {
15✔
1741
                    try {
5✔
1742
                        settings = JSON.parse(matches[1]);
5✔
1743
                    } catch (e) {
1744
                        settings = false;
2✔
1745
                    }
1746
                } else {
1747
                    settings = false;
10✔
1748
                }
1749

1750
                return settings;
15✔
1751
            })
1752
        );
1753
    }
1754

1755
}
1756

1757
/**
1758
 * Initializes the collapsed/expanded states of dragitems in structureboard.
1759
 *
1760
 * @method _initializeDragItemsStates
1761
 * @static
1762
 * @private
1763
 */
1764
// istanbul ignore next
1765
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
1766
    // removing duplicate entries
1767
    'use strict';
1768

1769
    const states = CMS.settings.states || [];
1770
    const sortedArr = states.sort();
1771
    const filteredArray = [];
1772

1773
    for (let i = 0; i < sortedArr.length; i++) {
1774
        if (sortedArr[i] !== sortedArr[i + 1]) {
1775
            filteredArray.push(sortedArr[i]);
1776
        }
1777
    }
1778
    CMS.settings.states = filteredArray;
1779

1780
    // loop through the items
1781
    $.each(CMS.settings.states, function(index, id) {
1782
        const el = $('.cms-draggable-' + id);
1783

1784
        // only add this class to elements which have immediate children
1785
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
1786
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1787
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1788
        }
1789
    });
1790
};
1791

1792
// shorthand for jQuery(document).ready();
1793
$(StructureBoard._initializeGlobalHandlers);
1✔
1794

1795
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