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

divio / django-cms / #30078

11 Nov 2025 02:05PM UTC coverage: 90.241% (-0.01%) from 90.251%
#30078

push

travis-ci

web-flow
Merge 03851acbd into 609c5e043

1266 of 1993 branches covered (63.52%)

20 of 29 new or added lines in 4 files covered. (68.97%)

271 existing lines in 4 files now uncovered.

8942 of 9909 relevant lines covered (90.24%)

11.21 hits per line

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

86.18
/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

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

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

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

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

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

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

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

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

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

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

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

68
        dd = new DiffDOM();
130✔
69

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

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

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

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

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

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

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

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

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

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

147
        // check if modes should be visible
148
        if (this.ui.dragareas.not('.cms-clipboard .cms-dragarea').length || this.ui.placeholders.length) {
107✔
149

150
            this.ui.toolbarModeSwitcher.find('.cms-btn').removeClass('cms-btn-disabled');
105✔
151
        }
152

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

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

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

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

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

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

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

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

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

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

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

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

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

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

278
        if (options.useHoveredPlugin && CMS.settings.mode !== 'structure') {
4✔
279
            that._showAndHighlightPlugin(options.successTimeout).then($.noop, $.noop);
1✔
280
        } else if (!options.useHoveredPlugin) {
3✔
281

282
            if (CMS.settings.mode === 'structure') {
2✔
283
                that.hide();
1✔
284
            } else if (CMS.settings.mode === 'edit') {
1!
285
                /* istanbul ignore else */ that.show();
1✔
286
            }
287
        }
288
    }
289

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

396
                const bodyRegex = /<body[\S\s]*?>([\S\s]*)<\/body>/gi;
1✔
397
                const body = document.createElement('div'); // Switch to plain JS due to problem with $(body)
1✔
398

399
                body.innerHTML = bodyRegex.exec(contentMarkup)[1];
1✔
400

401
                const structure = $(body.querySelector('.cms-structure-content'));
1✔
402
                const toolbar = $(body.querySelector('.cms-toolbar'));
1✔
403
                const scripts = $(body.querySelectorAll('[type="text/cms-template"]')); // cms scripts
1✔
404
                const pluginIds = this.getIds($(body.querySelectorAll('.cms-draggable')));
1✔
405
                const pluginData = StructureBoard._getPluginDataFromMarkup(
1✔
406
                    body,
407
                    pluginIds
408
                );
409

410
                Plugin._updateRegistry(pluginData);
1✔
411

412
                CMS.API.Toolbar._refreshMarkup(toolbar);
1✔
413

414
                $('body').append(scripts);
1✔
415
                $('.cms-structure-content').html(structure.html());
1✔
416
                triggerWindowResize();
1✔
417

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

432
                this.ui.sortables = $('.cms-draggables');
1✔
433
                this._drag();
1✔
434
                StructureBoard._initializeDragItemsStates();
1✔
435

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

443
    _requestMode(mode) {
444
        let url;
445

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

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

459
                return markup;
3✔
460
            });
461
        }
462

463
        return this[`_request${mode}`];
4✔
464
    }
465

466
    _loadContent() {
467
        const that = this;
47✔
468

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

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

497
                // istanbul ignore else
498
                if (title) {
1✔
499
                    document.title = title.text();
1✔
500
                }
501

502
                body = body.filter(function() {
1✔
503
                    const elem = $(this);
1✔
504

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

511
                [].slice.call(bodyAttributes).forEach(function(attr) {
1✔
512
                    bodyElement.attr(attr.name, attr.value);
2✔
513
                });
514

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

519
                bodyElement.append(body);
1✔
520
                $('head').append(head);
1✔
521
                bodyElement.prepend(toolbar);
1✔
522

523
                CMS.API.Toolbar._refreshMarkup(newToolbar);
1✔
524
                $(window).trigger('resize');
1✔
525

526
                Plugin._refreshPlugins();
1✔
527

528
                const scripts = $('script');
1✔
529

530
                // istanbul ignore next
531
                scripts.on('load', function() {
532
                    window.document.dispatchEvent(new Event('DOMContentLoaded'));
533
                    window.dispatchEvent(new Event('load'));
534
                });
535

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

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

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

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

564
        // reset toolbar positioning
565
        this.ui.toolbar.css('right', '');
48✔
566
        $('html').removeClass('cms-overflow');
48✔
567

568
        // set active item
569
        const modes = this.ui.toolbarModeLinks;
48✔
570

571
        modes.removeClass('cms-btn-active').eq(1).addClass('cms-btn-active');
48✔
572
        this.ui.html.removeClass('cms-structure-mode-structure').addClass('cms-structure-mode-content');
48✔
573

574
        CMS.settings.mode = 'edit';
48✔
575

576
        // hide canvas
577
        return this._loadContent().then(this._hideBoard.bind(this));
48✔
578
    }
579

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

594
        let id = null;
66✔
595
        const cls = el.attr('class').split(' ')[1];
66✔
596

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

609
        return id;
65✔
610
    }
611

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

623
        els.each(function() {
8✔
624
            array.push(that.getId($(this)));
13✔
625
        });
626
        return array;
8✔
627
    }
628

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

640
        modes.removeClass('cms-btn-active').eq(0).addClass('cms-btn-active');
104✔
641
        this.ui.html.removeClass('cms-structure-mode-content').addClass('cms-structure-mode-structure');
104✔
642

643
        this.ui.container.show();
104✔
644
        hideLoader();
104✔
645

646
        if (!init) {
104✔
647
            this._makeCondensed();
43✔
648
        }
649

650
        if (init && !this._loadedContent) {
104✔
651
            this._makeFullWidth();
61✔
652
        }
653

654
        this._preventScroll.start();
104✔
655
        this.ui.window.trigger('resize');
104✔
656
    }
657

658
    _makeCondensed() {
659
        this.condensed = true;
43✔
660
        this.ui.container.addClass('cms-structure-condensed');
43✔
661

662
        if (CMS.settings.mode === 'structure') {
43✔
663
            history.replaceState({}, '', CMS.config.settings.edit);
42✔
664
        }
665

666
        const width = this.ui.toolbar.width();
43✔
667
        let scrollBarWidth = this.ui.window[0].innerWidth - width;
43✔
668

669
        if (!scrollBarWidth) {
43✔
670
            scrollBarWidth = measureScrollbar();
6✔
671
        }
672

673
        this.ui.html.removeClass('cms-overflow');
43✔
674

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

681
    _makeFullWidth() {
682
        this.condensed = false;
61✔
683
        this.ui.container.removeClass('cms-structure-condensed');
61✔
684

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

690
        this.ui.container.css('right', 0);
61✔
691
    }
692

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

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

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

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

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

760
                    originalPluginContainer = ui.item.closest('.cms-draggables');
20✔
761

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

772
                        if (element.children().length === 0) {
80✔
773
                            element.removeClass('cms-hidden');
18✔
774
                        }
775
                    });
776

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

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

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

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

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

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

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

829
                    if (previousParentPlugin.length) {
10✔
830
                        eventData.previousParentPluginId = that.getId(previousParentPlugin);
3✔
831
                    }
832

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

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

846
                        if (element.children().length === 0) {
40✔
847
                            element.addClass('cms-hidden');
7✔
848
                        }
849
                    });
850

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

863
                    if (placeholder && placeholder.closest('.cms-clipboard-containers').length) {
13✔
864
                        return false;
1✔
865
                    }
866

867
                    // if parent has class disabled, dissalow drop
868
                    if (placeholder && placeholder.parent().hasClass('cms-drag-disabled')) {
12!
UNCOV
869
                        return false;
×
870
                    }
871

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

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

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

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

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

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

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

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

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

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

937
            this._drag(element);
41✔
938
        });
939
    }
940

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

951

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1040
    _listenToExternalUpdates() {
1041
        if (!Helpers._isStorageSupported) {
130✔
1042
            return;
3✔
1043
        }
1044

1045
        ls.on(storageKey, this._handleExternalUpdate.bind(this));
127✔
1046
    }
1047

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

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

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

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

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

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

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

1080
    _updateSingleContent(content) {
1081
        if (!content.pluginIds || content.pluginIds.length < 1 || content.html === undefined) {
3✔
1082
            // No valid content data available – update needed.
1083
            return true;
1✔
1084
        }
1085

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

1088
        if (nextEl.length < 1 || content.insert) {
2!
1089
            // Plugin not found, but placeholder is known – plugin was added.
1090
            nextEl = this._findNextElement(content.position, content.placeholder_id, content.pluginIds);
2✔
1091
        }
1092

1093
        if (nextEl.length === 0) {
2!
1094
            // Placeholder not found – update needed.
UNCOV
1095
            return true;
×
1096
        }
1097

1098
        nextEl.before(content.html);
2✔
1099

1100
        // Remove previous plugin and related script elements.
1101
        content.pluginIds.forEach(id => {
2✔
1102
            $(`:not(template).cms-plugin.cms-plugin-${id}`).remove();
3✔
1103
            $(`script[data-cms-plugin]#cms-plugin-${id}`).remove();
3✔
1104
        });
1105

1106
        // Update Sekizai blocks.
1107
        this._updateSekizai(content, 'css');
2✔
1108
        this._updateSekizai(content, 'js');
2✔
1109

1110
        return false;
2✔
1111
    }
1112

1113
    _updateContentFromDataBridge(data) {
1114
        if (!data || !data.content || data.content.length === 0) {
15✔
1115
            return true;
11✔
1116
        }
1117
        if (data.source_placeholder_id && !CMS._instances.some(
4✔
NEW
1118
            instance => instance.options.type === 'plugin' &&
×
1119
                instance.options.placeholder_id == data.source_placeholder_id // eslint-disable-line eqeqeq
1120
        )) {
1121
            // If last plugin was moved from a placeholder, the placeholder needs to be updated
1122
            return true; // Update needed
1✔
1123
        }
1124

1125
        for (const content of data.content) {
3✔
1126
            if (this._updateSingleContent(content)) {
3✔
1127
                return true; // Early exit if full content update is needed.
1✔
1128
            }
1129
        }
1130
        this._contentChanged(data.messages);
2✔
1131

1132
        if (this.scriptReferenceCount === 0) {
2!
1133
            // No scripts need to be loaded - content update is already done
1134
            StructureBoard._triggerRefreshEvents();
2✔
1135
        }
1136
        return false;
2✔
1137
    }
1138

1139
    _findNextElement(position, placeholder_id, excludedPlugins) {
1140
        let nextEl = $(`div.cms-placeholder.cms-placeholder-${placeholder_id}`);
×
1141
        const nextPlugins = CMS._instances.filter(instance =>
×
UNCOV
1142
            instance.options.type === 'plugin' &&
×
1143
            instance.options.placeholder_id == placeholder_id && // eslint-disable-line eqeqeq
1144
            instance.options.position >= position &&
1145
            !excludedPlugins.includes(1 * instance.options.plugin_id));
1146

UNCOV
1147
        if (nextPlugins.length > 0) {
×
1148
            // Plugins found with higher position, get the one with lowest position
1149
            const nextPluginId = nextPlugins.reduce((acc, instance) => {
×
UNCOV
1150
                return instance.options.position < acc.options.position ? instance : acc;
×
1151
            }, nextPlugins[0]).options.plugin_id;
1152

UNCOV
1153
            nextEl = $(`.cms-plugin.cms-plugin-${nextPluginId}.cms-plugin-start`);
×
1154
        }
UNCOV
1155
        return nextEl;
×
1156
    }
1157

1158
    _updateSekizai(data, block) {
1159
        if ((data[block] || '').length === 0) {
×
UNCOV
1160
            return;
×
1161
        }
1162

1163
        // Find existing candiates, selector and cursor to write to
1164
        let current;
1165
        let selector;
1166
        let location;
1167

1168
        if (block === 'css') {
×
1169
            selector = 'link, style, meta';
×
1170
            current = document.head.querySelectorAll(selector);
×
1171
            location = document.head;
×
1172
        } else if (block === 'js') {
×
1173
            selector = 'script';
×
1174
            current = document.body.querySelectorAll(selector);
×
UNCOV
1175
            location = document.body;
×
1176
        } else {
UNCOV
1177
            return;
×
1178
        }
1179

1180
        // Parse new block, by creating the diff
1181
        // Cannot use innerHTML since this would prevent scripts to be executed.
1182
        const newElements = document.createElement('div');
×
UNCOV
1183
        const diff = dd.diff(newElements, `<div>${data[block]}</div>`);
×
1184

UNCOV
1185
        dd.apply(newElements, diff);
×
1186

1187
        // Collect deferred scripts to ensure firing
UNCOV
1188
        this.scriptReferenceCount = 0;
×
1189

1190
        for (const element of newElements.querySelectorAll(selector)) {
×
1191
            if (StructureBoard._elementPresent(current, element)) {
×
UNCOV
1192
                element.remove();
×
1193
            } else {
1194
                if (element.hasAttribute('src')) {
×
1195
                    this.scriptReferenceCount++;
×
UNCOV
1196
                    element.onload = element.onerror = this._scriptLoaded.bind(this);
×
1197
                }
UNCOV
1198
                location.appendChild(element);
×
1199
            }
1200
        }
UNCOV
1201
        return this.scriptReferenceCount > 0;
×
1202
    }
1203

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

1207
        return $.ajax({
1✔
1208
            url: Helpers.updateUrlWithPath(
1209
                `${CMS.config.request.toolbar}?` +
1210
                    placeholderIds +
1211
                    '&' +
1212
                    `obj_id=${CMS.config.request.pk}&` +
1213
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}` +
1214
                    `&language=${encodeURIComponent(CMS.config.request.language)}`
1215
            )
1216
        });
1217
    }
1218

1219
    // i think this should probably be a separate class at this point that handles all the reloading
1220
    // stuff, it's a bit too much
1221
    // eslint-disable-next-line complexity
1222
    handleMovePlugin(data) {
1223
        if (data.plugin_parent) {
5✔
1224
            if (data.plugin_id) {
1!
1225
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);
1✔
1226

1227
                if (
1!
1228
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1229
                    !draggable.is('.cms-draggable-from-clipboard')
1230
                ) {
1231
                    draggable.remove();
1✔
1232
                }
1233
            }
1234

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

1243
            // external update, have to move the draggable to correct place first
1244
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1245
                const pluginOrder = data.plugin_order;
2✔
1246
                const index = findIndex(
2✔
1247
                    pluginOrder,
UNCOV
1248
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
×
1249
                );
1250
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1251

1252
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
UNCOV
1253
                    draggable = draggable.clone();
×
1254
                }
1255

1256
                if (index === 0) {
2!
UNCOV
1257
                    placeholderDraggables.prepend(draggable);
×
1258
                } else if (index !== -1) {
2!
UNCOV
1259
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
×
1260
                }
1261
            }
1262

1263
            // if we _are_ in the correct placeholder we still need to check if the order is correct
1264
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
1265
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1266
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1267
                const actualPluginOrder = this.getIds(
2✔
1268
                    placeholderDraggables.find('> .cms-draggable')
1269
                );
1270

1271
                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
2!
1272
                    // so the plugin order is not correct, means it's an external update and we need to move
1273
                    const pluginOrder = data.plugin_order;
2✔
1274
                    const index = findIndex(
2✔
1275
                        pluginOrder,
1276
                        pluginId => Number(pluginId) === Number(data.plugin_id)
3✔
1277
                    );
1278

1279
                    if (index === 0) {
2✔
1280
                        placeholderDraggables.prepend(draggable);
1✔
1281
                    } else if (index !== -1) {
1!
1282
                        placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
1✔
1283
                    }
1284
                }
1285
            }
1286

1287
            if (draggable.length) {
4✔
1288
                // empty the children first because replaceWith takes too much time
1289
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1290
                draggable.html('').replaceWith(data.html);
3✔
1291
            } else if (data.target_placeholder_id) {
1!
1292
                // copy from language
1293
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
1✔
1294
            }
1295
        }
1296

1297
        StructureBoard.actualizePlaceholders();
5✔
1298
        Plugin._updateRegistry(data.plugins);
5✔
1299
        data.plugins.forEach(pluginData => {
5✔
1300
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
7✔
1301
        });
1302

1303
        StructureBoard._initializeDragItemsStates();
5✔
1304

1305
        this.ui.sortables = $('.cms-draggables');
5✔
1306
        this._dragRefresh();
5✔
1307
        return this._updateContentFromDataBridge(data);
5✔
1308
    }
1309

1310
    handleCopyPlugin(data) {
1311
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1312
            CMS.API.Clipboard.modal.close();
1✔
1313
        }
1314

1315
        $('.cms-clipboard-containers').html(data.html);
2✔
1316
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1317

1318
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1319

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

1322
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1323
        CMS._plugins.push(pluginData);
2✔
1324
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1325

1326
        CMS.API.Clipboard = new Clipboard();
2✔
1327

1328
        Plugin._updateClipboard();
2✔
1329

1330
        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');
2✔
1331
        const html = clipboardDraggable.parent().html();
2✔
1332

1333
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1334
        CMS.API.Clipboard._enableTriggers();
2✔
1335

1336
        this.ui.sortables = $('.cms-draggables');
2✔
1337
        this._dragRefresh();
2✔
1338
        return true; // update needed
2✔
1339
    }
1340

1341
    handleCutPlugin(data) {
1342
        const updateNeededFromDelete = this.handleDeletePlugin(data);
1✔
1343

1344
        this.handleCopyPlugin(data);
1✔
1345
        return updateNeededFromDelete;
1✔
1346
    }
1347

1348
    _extractMessages(doc) {
1349
        let messageList = doc.find('.messagelist');
6✔
1350
        let messages = messageList.find('li');
6✔
1351

1352
        if (!messageList.length || !messages.length) {
6✔
1353
            messageList = doc.find('[data-cms-messages-container]');
5✔
1354
            messages = messageList.find('[data-cms-message]');
5✔
1355
        }
1356

1357
        if (messages.length) {
6✔
1358
            messageList.remove();
3✔
1359

1360
            return compact(
3✔
1361
                messages.toArray().map(el => {
1362
                    const msgEl = $(el);
7✔
1363
                    const message = $(el).text().trim();
7✔
1364

1365
                    if (message) {
7✔
1366
                        return {
6✔
1367
                            message,
1368
                            error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
10✔
1369
                        };
1370
                    }
1371
                })
1372
            );
1373
        }
1374

1375
        return [];
3✔
1376
    }
1377

1378
    refreshContent(contentMarkup) {
1379
        this._requestcontent = null;
3✔
1380
        if (!this._loadedStructure) {
3!
1381
            this._requeststructure = null;
3✔
1382
        }
1383
        const newDoc = new DOMParser().parseFromString(contentMarkup, 'text/html');
3✔
1384

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

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

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

1392
        const messages = this._extractMessages($(newDoc));
3✔
1393

1394
        if (messages.length) {
3✔
1395
            setTimeout(() =>
1✔
1396
                messages.forEach(message => {
1✔
1397
                    CMS.API.Messages.open(message);
1✔
1398
                })
1399
            );
1400
        }
1401
        const headDiff = dd.diff(document.head, nodeToObj(newDoc.head));
3✔
1402

1403
        this._replaceBodyWithHTML(newDoc.body);
3✔
1404
        dd.apply(document.head, headDiff);
3✔
1405

1406
        toolbar.prependTo(document.body);
3✔
1407
        CMS.API.Toolbar._refreshMarkup(newToolbar);
3✔
1408

1409
        $('.cms-structure-content').scrollTop(structureScrollTop);
3✔
1410
        this._loadedContent = true;
3✔
1411
        this._contentChanged();
3✔
1412
    }
1413

1414
    _contentChanged(messages) {
1415
        Plugin._refreshPlugins();
3✔
1416
        if (messages) {
3!
1417
            CMS.API.Messages.close();
×
1418
            if (messages.length) {
×
1419
                CMS.API.Messages.open({
×
1420
                    message: messages.map(message => `<p>${message.message}</p>`).join(''),
×
UNCOV
1421
                    error: messages.some(message => message.level === 'error')
×
1422
                });
1423
            }
1424
        }
1425
    }
1426

1427
    handleAddPlugin(data) {
1428
        if (data.plugin_parent) {
2✔
1429
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1430
        } else {
1431
            // the one in the clipboard is first
1432
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
1✔
1433
        }
1434

1435
        StructureBoard.actualizePlaceholders();
2✔
1436
        Plugin._updateRegistry(data.structure.plugins);
2✔
1437
        data.structure.plugins.forEach(pluginData => {
2✔
1438
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
3✔
1439
        });
1440

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

1446
    handleEditPlugin(data) {
1447
        if (data.plugin_parent) {
2✔
1448
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1449
        } else {
1450
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
1✔
1451
        }
1452

1453
        Plugin._updateRegistry(data.structure.plugins);
2✔
1454

1455
        data.structure.plugins.forEach(pluginData => {
2✔
1456
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
2✔
1457
        });
1458

1459
        this.ui.sortables = $('.cms-draggables');
2✔
1460
        this._dragRefresh();
2✔
1461
        return this._updateContentFromDataBridge(data.structure);
2✔
1462
    }
1463

1464
    handleDeletePlugin(data) {
1465
        const { placeholder_id } = CMS._instances.find(
2✔
1466
            // data.plugin_id might be string
1467
            plugin => plugin && plugin.options.plugin_id == data.plugin_id // eslint-disable-line eqeqeq
2✔
1468
        ).options;
1469
        const draggable = $('.cms-draggable-' + data.plugin_id);
2✔
1470
        const children = draggable.find('.cms-draggable');
2✔
1471
        let deletedPluginIds = [data.plugin_id];
2✔
1472
        let parent = draggable.parent().closest('.cms-draggable');
2✔
1473

1474
        if (!parent.length) {
2✔
1475
            parent = draggable.closest('.cms-dragarea');
1✔
1476
        }
1477

1478
        if (children.length) {
2✔
1479
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
1✔
1480
        }
1481

1482
        draggable.remove();
2✔
1483

1484
        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
2✔
1485
        StructureBoard.actualizePlaceholders();
2✔
1486
        const contentData = (data.structure || data); // delete has content in data.structure, cut in data
2✔
1487

1488
        deletedPluginIds.forEach(function(pluginId) {
2✔
1489
            if (!contentData.content) {
3!
1490
                $(`.cms-plugin.cms-plugin-${pluginId}`).remove(); // Remove from content
3✔
1491
            }
1492
            $(`script[data-cms-plugin]#cms-plugin-${pluginId}`).remove(); // Remove script elements
3✔
1493
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
5✔
1494
            remove(
3✔
1495
                CMS._instances,
1496
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
5✔
1497
            );
1498
        });
1499

1500
        const lastPluginDeleted = CMS._instances.find(
2✔
1501
            plugin => plugin.options.placeholder_id == placeholder_id // eslint-disable-line eqeqeq
1✔
1502
        ) === undefined;
1503

1504
        return lastPluginDeleted || contentData.content && this._updateContentFromDataBridge(contentData);
2!
1505
    }
1506

1507
    handleClearPlaceholder(data) {
1508
        const deletedIds = CMS._instances
1✔
1509
            .filter(instance => {
1510
                if (
3✔
1511
                    instance.options.plugin_id &&
6✔
1512
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
1513
                ) {
1514
                    return true;
2✔
1515
                }
1516
            })
1517
            .map(instance => instance.options.plugin_id);
2✔
1518

1519
        deletedIds.forEach(id => {
1✔
1520
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
5✔
1521
            remove(
2✔
1522
                CMS._instances,
1523
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
5✔
1524
            );
1525

1526
            $(`.cms-draggable-${id}`).remove();
2✔
1527
        });
1528

1529
        StructureBoard.actualizePlaceholders();
1✔
1530
        return true;
1✔
1531
    }
1532

1533
    /**
1534
     * Similar to CMS.Plugin populates globally required
1535
     * variables, that only need querying once, e.g. placeholders.
1536
     *
1537
     * @method _initializeGlobalHandlers
1538
     * @static
1539
     * @private
1540
     */
1541
    static _initializeGlobalHandlers() {
1542
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
77✔
1543
    }
1544

1545
    /**
1546
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
1547
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
1548
     *
1549
     * @function actualizePlaceholders
1550
     * @private
1551
     */
1552
    static actualizePlaceholders() {
1553
        placeholders.each(function() {
160✔
1554
            const placeholder = $(this);
477✔
1555
            const copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');
477✔
1556

1557
            if (
477✔
1558
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
1559
                    .length
1560
            ) {
1561
                placeholder.removeClass('cms-dragarea-empty');
159✔
1562
                copyAll.removeClass('cms-submenu-item-disabled');
159✔
1563
                copyAll.find('> a').removeAttr('aria-disabled');
159✔
1564
            } else {
1565
                placeholder.addClass('cms-dragarea-empty');
318✔
1566
                copyAll.addClass('cms-submenu-item-disabled');
318✔
1567
                copyAll.find('> a').attr('aria-disabled', 'true');
318✔
1568
            }
1569
        });
1570

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

1573
        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
160!
UNCOV
1574
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
×
1575
        }
1576
    }
1577

1578
    /**
1579
     * actualizePluginCollapseStatus
1580
     *
1581
     * @public
1582
     * @param {String} pluginId open the plugin if it should be open
1583
     */
1584
    static actualizePluginCollapseStatus(pluginId) {
1585
        const el = $(`.cms-draggable-${pluginId}`);
1✔
1586
        const open = find(CMS.settings.states, openPluginId => Number(openPluginId) === Number(pluginId));
1✔
1587

1588
        // only add this class to elements which have a draggable area
1589
        // istanbul ignore else
1590
        if (open && el.find('> .cms-draggables').length) {
1✔
1591
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1✔
1592
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1✔
1593
        }
1594
    }
1595

1596
    /**
1597
     * @function actualizePluginsCollapsibleStatus
1598
     * @private
1599
     * @param {jQuery} els lists of plugins (.cms-draggables)
1600
     */
1601
    static actualizePluginsCollapsibleStatus(els) {
1602
        els.each(function() {
9✔
1603
            const childList = $(this);
14✔
1604
            const pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');
14✔
1605

1606
            if (childList.children().length) {
14✔
1607
                pluginDragItem.addClass('cms-dragitem-collapsable');
10✔
1608
                if (childList.children().is(':visible')) {
10!
1609
                    pluginDragItem.addClass('cms-dragitem-expanded');
10✔
1610
                }
1611
            } else {
1612
                pluginDragItem.removeClass('cms-dragitem-collapsable');
4✔
1613
            }
1614
        });
1615
    }
1616

1617
    /**
1618
     * Replaces the current document body with the provided HTML content.
1619
     *
1620
     * This method removes all existing script elements from the document body,
1621
     * replaces the body content with the new HTML, and then re-inserts new script
1622
     * elements to ensure they are executed.
1623
     *
1624
     * @param {HTMLElement} body - The new HTML content to replace the current body.
1625
     *
1626
     * @private
1627
     */
1628
    _replaceBodyWithHTML(body) {
1629
        // Remove (i.e. detach) old scripts
UNCOV
1630
        const oldScripts = document.body.querySelectorAll('script:not([type="application/json"])');
×
1631

UNCOV
1632
        oldScripts.forEach(script => script.remove());
×
1633

1634
        // Replace the body content
UNCOV
1635
        document.body.innerHTML = body.innerHTML;
×
1636

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

UNCOV
1640
        this._processNewScripts(newScripts, oldScripts);
×
1641

1642
        if (this.scriptReferenceCount === 0) {
×
UNCOV
1643
            StructureBoard._triggerRefreshEvents();
×
1644
        }
1645
    }
1646

1647
    /**
1648
     * Processes new script elements by comparing them with old script elements.
1649
     * If a new script is not present in the old scripts, it rewrites the script to the DOM to force execution.
1650
     *
1651
     * @param {NodeList} newScripts - A list of new script elements to be processed.
1652
     * @param {NodeList} oldScripts - A list of old script elements to compare against.
1653
     * @private
1654
     */
1655
    _processNewScripts(newScripts, oldScripts) {
1656
        newScripts.forEach(script => {
×
UNCOV
1657
            if (!StructureBoard._elementPresent(oldScripts, script)) {
×
1658
                // Rewrite script to DOM to force execution
UNCOV
1659
                const newScript = document.createElement('script');
×
1660

1661
                // Copy attributes
1662
                Array.from(script.attributes).forEach(attr => {
×
UNCOV
1663
                    newScript.setAttribute(attr.name, attr.value);
×
1664
                });
UNCOV
1665
                if (script.src) {
×
1666
                    // Needs to be loaded from a server
1667
                    this.scriptReferenceCount++;
×
UNCOV
1668
                    newScript.onload = newScript.onerror = this._scriptLoaded.bind(this);
×
1669
                } else {
1670
                    // Inline script
UNCOV
1671
                    newScript.textContent = script.textContent;
×
1672
                }
1673
                script.parentNode.insertBefore(newScript, script.nextSibling);
×
UNCOV
1674
                script.remove();
×
1675
            }
1676
        });
1677
    }
1678

1679
    /**
1680
     * Checks if a given element is present in the current set of elements.
1681
     *
1682
     * @param {NodeList} current - The current set of elements to check against.
1683
     * @param {Element} element - The element to check for presence.
1684
     * @returns {boolean} - Returns true if the element is present in the current set, otherwise false.
1685
     * @private
1686
     */
1687
    static _elementPresent(current, element) {
UNCOV
1688
        const markup = element.outerHTML;
×
1689

UNCOV
1690
        return [...current].some(el => el.outerHTML === markup);
×
1691
    }
1692

1693
    /**
1694
     * Handles the event when a script is loaded.
1695
     * Decrements the script reference count and, if it reaches zero,
1696
     * dispatches a 'load' event on the window and triggers a 'cms-content-refresh' event.
1697
     *
1698
     * @private
1699
     */
1700
    _scriptLoaded() {
1701
        if (--this.scriptReferenceCount < 1) {
×
UNCOV
1702
            StructureBoard._triggerRefreshEvents();
×
1703
        }
1704
    }
1705

1706
    /**
1707
     * Triggers refresh events on the window and document.
1708
     *
1709
     * This method dispatches the 'DOMContentLoaded' event on the document,
1710
     * the 'load' event on the window, and triggers the 'cms-content-refresh'
1711
     * event using jQuery on the window. The events are dispatched asynchronously
1712
     * to ensure that the current execution context is completed before the events
1713
     * are triggered.
1714
     *
1715
     * @private
1716
     * @static
1717
     */
1718
    static _triggerRefreshEvents() {
1719
        setTimeout(() => {
2✔
1720
            Helpers._getWindow().document.dispatchEvent(new Event('DOMContentLoaded'));
2✔
1721
            Helpers._getWindow().dispatchEvent(new Event('load'));
2✔
1722
            Helpers._getWindow().dispatchEvent(new Event('cms-content-refresh'));
2✔
1723
        }, 0);
1724
    }
1725

1726
    highlightPluginFromUrl() {
1727
        const hash = window.location.hash;
120✔
1728
        const regex = /cms-plugin-(\d+)/;
120✔
1729

1730
        if (!hash || !hash.match(regex)) {
120!
1731
            return;
120✔
1732
        }
1733

UNCOV
1734
        const pluginId = regex.exec(hash)[1];
×
1735

1736
        if (this._loadedContent) {
×
UNCOV
1737
            Plugin._highlightPluginContent(pluginId, {
×
1738
                seeThrough: true,
1739
                prominent: true,
1740
                delay: 3000
1741
            });
1742
        }
1743
    }
1744

1745
    /**
1746
     * Get's plugins data from markup
1747
     *
1748
     * @method _getPluginDataFromMarkup
1749
     * @private
1750
     * @param {Node} body
1751
     * @param {Array<Number | String>} pluginIds
1752
     * @returns {Array<[String, Object]>}
1753
     */
1754
    static _getPluginDataFromMarkup(body, pluginIds) {
1755
        return compact(
9✔
1756
            pluginIds.map(pluginId => {
1757
                const pluginData = body.querySelector(`#cms-plugin-${pluginId}`);
15✔
1758
                let settings;
1759

1760
                if (pluginData) {
15✔
1761
                    try {
5✔
1762
                        settings = JSON.parse(pluginData.textContent);
5✔
1763
                    } catch {
1764
                        settings = false;
2✔
1765
                    }
1766
                } else {
1767
                    settings = false;
10✔
1768
                }
1769

1770
                return settings;
15✔
1771
            })
1772
        );
1773
    }
1774

1775
}
1776

1777
/**
1778
 * Initializes the collapsed/expanded states of dragitems in structureboard.
1779
 *
1780
 * @method _initializeDragItemsStates
1781
 * @static
1782
 * @private
1783
 */
1784
// istanbul ignore next
1785
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
1786
    // removing duplicate entries
1787
    'use strict';
1788

1789
    const states = CMS.settings.states || [];
1790
    const sortedArr = states.sort();
1791
    const filteredArray = [];
1792

1793
    for (let i = 0; i < sortedArr.length; i++) {
1794
        if (sortedArr[i] !== sortedArr[i + 1]) {
1795
            filteredArray.push(sortedArr[i]);
1796
        }
1797
    }
1798
    CMS.settings.states = filteredArray;
1799

1800
    // loop through the items
1801
    $.each(CMS.settings.states, function(index, id) {
1802
        const el = $('.cms-draggable-' + id);
1803

1804
        // only add this class to elements which have immediate children
1805
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
1806
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1807
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1808
        }
1809
    });
1810
};
1811

1812
// shorthand for jQuery(document).ready();
1813
$(StructureBoard._initializeGlobalHandlers);
1✔
1814

1815
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