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

divio / django-cms / #30625

07 May 2026 01:13PM UTC coverage: 90.235% (+14.3%) from 75.915%
#30625

push

travis-ci

web-flow
Merge 13c5f046e into 155395fcc

1389 of 2213 branches covered (62.77%)

9546 of 10579 relevant lines covered (90.24%)

11.18 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

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 './dom-diff';
14
import once from 'lodash-es/once.js';
15
import remove from 'lodash-es/remove.js';
16
import isEqual from 'lodash-es/isEqual.js';
17
import zip from 'lodash-es/zip.js';
18
import ls from 'local-storage';
19

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

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

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

30
/* global DOMParser */
31

32
const storageKey = 'cms-structure';
1✔
33

34
let dd;
35
let placeholders;
36
let originalPluginContainer;
37

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

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

48
const arrayEquals = (a1, a2) => zip(a1, a2).every(([a, b]) => a === b);
2✔
49

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

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

71
        dd = new DiffDOM();
130✔
72

73
        // setup initial stuff
74
        const setup = this._setup();
130✔
75

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

84
        setTimeout(() => this.highlightPluginFromUrl(), 0);
130✔
85
        this._listenToExternalUpdates();
130✔
86
    }
87

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

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

115
        // Set initial touch-action for vertical scrolling
116
        if (this.ui.content[0]) {
130✔
117
            this.ui.content[0].style.touchAction = 'pan-y';
109✔
118
        }
119
    }
120

121
    /**
122
     * Initial setup (and early bail if specific
123
     * elements do not exist).
124
     *
125
     * @method _setup
126
     * @private
127
     * @returns {Boolean|void}
128
     */
129
    _setup() {
130
        const that = this;
130✔
131

132
        // cancel if there is no structure / content switcher
133
        if (!this.ui.toolbarModeSwitcher.length) {
130✔
134
            return false;
23✔
135
        }
136

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

148
        if (CMS.config.settings.legacy_mode) {
107✔
149
            that._loadedStructure = true;
1✔
150
            that._loadedContent = true;
1✔
151
        }
152

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

156
            this.ui.toolbarModeSwitcher.find('.cms-btn').removeClass('cms-btn-disabled');
105✔
157
        }
158

159
        // add drag & drop functionality
160
        // istanbul ignore next
161
        $('.cms-draggable:not(.cms-drag-disabled)').one(
162
            'pointerover.cms.drag',
163
            once(() => {
164
                $('.cms-draggable:not(.cms-drag-disabled)').off('pointerover.cms.drag');
165
                this._drag();
166
            })
167
        );
168
    }
169

170
    _preloadOppositeMode() {
171
        if (CMS.config.settings.legacy_mode) {
3✔
172
            return;
1✔
173
        }
174
        const WAIT_BEFORE_PRELOADING = 2000;
2✔
175

176
        $(Helpers._getWindow()).one('load', () => {
2✔
177
            setTimeout(() => {
2✔
178
                if (this._loadedStructure) {
2✔
179
                    this._requestMode('content');
1✔
180
                } else {
181
                    this._requestMode('structure');
1✔
182
                }
183
            }, WAIT_BEFORE_PRELOADING);
184
        });
185
    }
186

187
    _events() {
188
        this.ui.window.on('resize.cms.structureboard', () => {
130✔
189
            if (!this._loadedContent || CMS.config.mode !== 'draft') {
8,853!
190
                return;
8,853✔
191
            }
192
            const width = this.ui.window[0].innerWidth;
×
193
            const BREAKPOINT = 1024;
×
194

195
            if (width > BREAKPOINT && !this.condensed) {
×
196
                this._makeCondensed();
×
197
            }
198

199
            if (width <= BREAKPOINT && this.condensed) {
×
200
                this._makeFullWidth();
×
201
            }
202
        });
203
    }
204

205
    /**
206
     * Sets up events handlers for switching
207
     * structureboard modes.
208
     *
209
     * @method _setupModeSwitcher
210
     * @private
211
     */
212
    _setupModeSwitcher() {
213
        const modes = this.ui.toolbarModeLinks;
130✔
214
        let cmdPressed;
215

216
        $(Helpers._getWindow())
130✔
217
            .on(this.keyUpAndDown, e => {
218
                if (
×
219
                    e.keyCode === KEYS.CMD_LEFT ||
×
220
                    e.keyCode === KEYS.CMD_RIGHT ||
221
                    e.keyCode === KEYS.CMD_FIREFOX ||
222
                    e.keyCode === KEYS.SHIFT ||
223
                    e.keyCode === KEYS.CTRL
224
                ) {
225
                    cmdPressed = true;
×
226
                }
227
                if (e.type === 'keyup') {
×
228
                    cmdPressed = false;
×
229
                }
230
            })
231
            .on('blur', () => {
232
                cmdPressed = false;
×
233
            });
234

235
        // show edit mode
236
        modes.on(this.click, e => {
130✔
237
            e.preventDefault();
4✔
238
            e.stopImmediatePropagation();
4✔
239

240
            if (modes.hasClass('cms-btn-disabled')) {
4!
241
                return;
×
242
            }
243

244
            if (cmdPressed && e.type === 'click') {
4!
245
                // control the behaviour when ctrl/cmd is pressed
246
                Helpers._getWindow().open(modes.attr('href'), '_blank');
×
247
                return;
×
248
            }
249

250
            if (CMS.settings.mode === 'edit') {
4✔
251
                this.show();
2✔
252
            } else {
253
                this.hide();
2✔
254
            }
255
        });
256

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

275
    /**
276
     * @method _toggleStructureBoard
277
     * @private
278
     * @param {Object} [options] options
279
     * @param {Boolean} [options.useHoveredPlugin] should the plugin be taken into account
280
     */
281
    _toggleStructureBoard(options = {}) {
2✔
282
        const that = this;
4✔
283

284
        if (options.useHoveredPlugin && CMS.settings.mode !== 'structure') {
4✔
285
            that._showAndHighlightPlugin(options.successTimeout).then($.noop, $.noop);
1✔
286
        } else if (!options.useHoveredPlugin) {
3✔
287
            if (CMS.settings.mode === 'structure') {
2✔
288
                that.hide();
1✔
289
            } else if (CMS.settings.mode === 'edit') {
1!
290
                /* istanbul ignore else */
291
                that.show();
1✔
292
            }
293
        }
294
    }
295

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

312
        if (!CMS.API.Tooltip) {
3✔
313
            return Promise.resolve(false);
1✔
314
        }
315

316
        const tooltip = CMS.API.Tooltip.domElem;
2✔
317
        const HIGHLIGHT_TIMEOUT = 10;
2✔
318
        const DRAGGABLE_HEIGHT = 50; // it's not precisely 50, but it fits
2✔
319

320
        if (!tooltip.is(':visible')) {
2✔
321
            return Promise.resolve(false);
1✔
322
        }
323

324
        const pluginId = tooltip.data('plugin_id');
1✔
325

326
        return this.show().then(function() {
1✔
327
            const draggable = $('.cms-draggable-' + pluginId);
1✔
328
            const doc = $(document);
1✔
329
            const currentExpandmode = doc.data('expandmode');
1✔
330

331
            // expand necessary parents
332
            doc.data('expandmode', false);
1✔
333
            draggable
1✔
334
                .parents('.cms-draggable')
335
                .find('> .cms-dragitem-collapsable:not(".cms-dragitem-expanded") > .cms-dragitem-text')
336
                .each((i, el) => $(el).triggerHandler(Plugin.click));
×
337

338
            setTimeout(() => doc.data('expandmode', currentExpandmode));
1✔
339
            setTimeout(function() {
1✔
340
                const offsetParent = draggable.offsetParent();
1✔
341
                const position = draggable.position().top + offsetParent.scrollTop();
1✔
342

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

345
                Plugin._highlightPluginStructure(draggable.find('.cms-dragitem:first'), { successTimeout, seeThrough });
1✔
346
            }, HIGHLIGHT_TIMEOUT);
347
        });
348
    }
349

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

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

372
            if (!scrollBarWidth && init) {
61!
373
                scrollBarWidth = measureScrollbar();
61✔
374
            }
375

376
            if (scrollBarWidth) {
61!
377
                this.ui.toolbar.css('right', scrollBarWidth);
61✔
378
            }
379
        }
380
        // apply new settings
381
        CMS.settings.mode = 'structure';
104✔
382
        Helpers.setSettings(CMS.settings);
104✔
383

384
        return this._loadStructure().then(this._showBoard.bind(this, init));
104✔
385
    }
386

387
    _loadStructure() {
388
        // case when structure mode is already loaded
389
        if (CMS.config.settings.mode === 'structure' || this._loadedStructure) {
100✔
390
            return Promise.resolve();
99✔
391
        }
392

393
        showLoader();
1✔
394
        return this
1✔
395
            ._requestMode('structure')
396
            .done(contentMarkup => {
397
                this._requeststructure = null;
1✔
398
                hideLoader();
1✔
399

400
                CMS.settings.states = Helpers.getSettings().states;
1✔
401

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

405
                body.innerHTML = bodyRegex.exec(contentMarkup)[1];
1✔
406

407
                const structure = $(body.querySelector('.cms-structure-content'));
1✔
408
                const toolbar = $(body.querySelector('.cms-toolbar'));
1✔
409
                const scripts = $(body.querySelectorAll('[type="text/cms-template"]')); // cms scripts
1✔
410
                const pluginIds = this.getIds($(body.querySelectorAll('.cms-draggable')));
1✔
411
                const pluginData = StructureBoard._getPluginDataFromMarkup(
1✔
412
                    body,
413
                    pluginIds
414
                );
415

416
                Plugin._updateRegistry(pluginData);
1✔
417

418
                CMS.API.Toolbar._refreshMarkup(toolbar);
1✔
419

420
                $('body').append(scripts);
1✔
421
                $('.cms-structure-content').html(structure.html());
1✔
422
                triggerWindowResize();
1✔
423

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

438
                this.ui.sortables = $('.cms-draggables:not(.cms-drag-disabled)');
1✔
439
                this._drag();
1✔
440
                StructureBoard._initializeDragItemsStates();
1✔
441

442
                this._loadedStructure = true;
1✔
443
            })
444
            .fail(function() {
445
                window.location.href = CMS.config.settings.structure;
×
446
            });
447
    }
448

449
    _requestMode(mode) {
450
        let url;
451

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

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

465
                return markup;
3✔
466
            });
467
        }
468

469
        return this[`_request${mode}`];
4✔
470
    }
471

472
    _loadContent() {
473
        const that = this;
47✔
474

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

480
        showLoader();
1✔
481
        return that
1✔
482
            ._requestMode('content')
483
            .done(function(contentMarkup) {
484
                that._requestcontent = null;
1✔
485
                hideLoader();
1✔
486
                const htmlRegex = /<html([\S\s]*?)>[\S\s]*<\/html>/gi;
1✔
487
                const bodyRegex = /<body([\S\s]*?)>([\S\s]*)<\/body>/gi;
1✔
488
                const headRegex = /<head[\S\s]*?>([\S\s]*)<\/head>/gi;
1✔
489
                const matches = bodyRegex.exec(contentMarkup);
1✔
490
                // we don't handle cases where body or html doesn't exist, cause it's highly unlikely
491
                // and will result in way more troubles for cms than this
492
                const bodyAttrs = matches[1];
1✔
493
                let body = $(matches[2]);
1✔
494
                const head = $(headRegex.exec(contentMarkup)[1]);
1✔
495
                const htmlAttrs = htmlRegex.exec(contentMarkup)[1];
1✔
496
                const bodyAttributes = $('<div ' + bodyAttrs + '></div>')[0].attributes;
1✔
497
                const htmlAttributes = $('<div ' + htmlAttrs + '></div>')[0].attributes;
1✔
498
                const newToolbar = body.find('.cms-toolbar');
1✔
499
                // Capture old scripts before detaching toolbar so CMS bundles are included
500
                const oldScripts = document.body.querySelectorAll('script:not([type="application/json"])');
1✔
501
                const toolbar = $('.cms').add('[data-cms]').detach();
1✔
502
                const title = head.filter('title');
1✔
503
                const bodyElement = $('body');
1✔
504

505
                // istanbul ignore else
506
                if (title) {
1✔
507
                    document.title = title.text();
1✔
508
                }
509

510
                body = body.filter(function() {
1✔
511
                    const elem = $(this);
1✔
512

513
                    return (
1✔
514
                        !elem.is('.cms#cms-top') && !elem.is('[data-cms]:not([data-cms-generic])') // toolbar
2✔
515
                    ); // cms scripts
516
                });
517
                body.find('[data-cms]:not([data-cms-generic])').remove(); // cms scripts
1✔
518

519
                [].slice.call(bodyAttributes).forEach(function(attr) {
1✔
520
                    bodyElement.attr(attr.name, attr.value);
2✔
521
                });
522

523
                [].slice.call(htmlAttributes).forEach(function(attr) {
1✔
524
                    $('html').attr(attr.name, attr.value);
2✔
525
                });
526

527
                bodyElement.append(body);
1✔
528
                $('head').append(head);
1✔
529
                bodyElement.prepend(toolbar);
1✔
530

531
                CMS.API.Toolbar._refreshMarkup(newToolbar);
1✔
532
                $(window).trigger('resize');
1✔
533

534
                Plugin._refreshPlugins();
1✔
535

536
                const newScripts = document.body.querySelectorAll('script:not([type="application/json"])');
1✔
537

538
                that._processNewScripts(newScripts, oldScripts);
1✔
539

540
                if (that.scriptReferenceCount === 0) {
1!
541
                    StructureBoard._triggerRefreshEvents();
1✔
542
                }
543

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

546
                if (unhandledPlugins.length) {
1!
547
                    CMS.API.Messages.open({
×
548
                        message: CMS.config.lang.unhandledPageChange
549
                    });
550
                    window.location.href = CMS.config.settings.edit;
×
551
                }
552

553
                that._loadedContent = true;
1✔
554
            })
555
            .fail(function() {
556
                window.location.href = CMS.config.settings.edit;
×
557
            });
558
    }
559

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

572
        // reset toolbar positioning
573
        this.ui.toolbar.css('right', '');
48✔
574
        $('html').removeClass('cms-overflow');
48✔
575

576
        // set active item
577
        const modes = this.ui.toolbarModeLinks;
48✔
578

579
        modes.removeClass('cms-btn-active').eq(1).addClass('cms-btn-active');
48✔
580
        this.ui.html.removeClass('cms-structure-mode-structure').addClass('cms-structure-mode-content');
48✔
581

582
        CMS.settings.mode = 'edit';
48✔
583

584
        // hide canvas
585
        return this._loadContent().then(this._hideBoard.bind(this));
48✔
586
    }
587

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

602
        let id = null;
66✔
603
        const cls = el.attr('class').split(' ')[1];
66✔
604

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

617
        return id;
65✔
618
    }
619

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

631
        els.each(function() {
8✔
632
            array.push(that.getId($(this)));
13✔
633
        });
634
        return array;
8✔
635
    }
636

637
    /**
638
     * Actually shows the board canvas.
639
     *
640
     * @method _showBoard
641
     * @param {Boolean} init init
642
     * @private
643
     */
644
    _showBoard(init) {
645
        // set active item
646
        const modes = this.ui.toolbarModeLinks;
104✔
647

648
        modes.removeClass('cms-btn-active').eq(0).addClass('cms-btn-active');
104✔
649
        this.ui.html.removeClass('cms-structure-mode-content').addClass('cms-structure-mode-structure');
104✔
650

651
        this.ui.container.show();
104✔
652
        hideLoader();
104✔
653

654
        if (!init) {
104✔
655
            this._makeCondensed();
43✔
656
        }
657

658
        if (init && !this._loadedContent) {
104✔
659
            this._makeFullWidth();
61✔
660
        }
661

662
        this.ui.window.trigger('resize');
104✔
663
    }
664

665
    _makeCondensed() {
666
        this.condensed = true;
43✔
667
        this.ui.container.addClass('cms-structure-condensed');
43✔
668

669
        if (CMS.settings.mode === 'structure') {
43✔
670
            history.replaceState({}, '', CMS.config.settings.edit);
42✔
671
        }
672

673
        const width = this.ui.toolbar.width();
43✔
674
        let scrollBarWidth = this.ui.window[0].innerWidth - width;
43✔
675

676
        if (!scrollBarWidth) {
43✔
677
            scrollBarWidth = measureScrollbar();
6✔
678
        }
679

680
        this.ui.html.removeClass('cms-overflow');
43✔
681

682
        if (scrollBarWidth) {
43!
683
            // this.ui.toolbar.css('right', scrollBarWidth);
684
            this.ui.container.css('right', -scrollBarWidth);
43✔
685
        }
686
    }
687

688
    _makeFullWidth() {
689
        this.condensed = false;
61✔
690
        this.ui.container.removeClass('cms-structure-condensed');
61✔
691

692
        if (CMS.settings.mode === 'structure') {
61!
693
            history.replaceState({}, '', CMS.config.settings.structure);
61✔
694
            $('html.cms-structure-mode-structure').addClass('cms-overflow');
61✔
695
        }
696

697
        this.ui.container.css('right', 0);
61✔
698
    }
699

700
    /**
701
     * Hides the board canvas.
702
     *
703
     * @method _hideBoard
704
     * @private
705
     */
706
    _hideBoard() {
707
        // hide elements
708
        this.ui.container.hide();
48✔
709

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

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

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

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

767
                    originalPluginContainer = ui.item.closest('.cms-draggables');
20✔
768

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

779
                        if (element.children().length === 0) {
80✔
780
                            element.removeClass('cms-hidden');
18✔
781
                        }
782
                    });
783

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

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

801
                beforeStop: function(event, ui) {
802
                    that.dragging = false;
4✔
803
                    ui.item.removeClass('cms-is-dragging cms-draggable-stack');
4✔
804
                    that.ui.doc.off('keyup.cms.interrupt');
4✔
805
                    // Re-enable vertical scrolling after drag
806
                    that.ui.content[0].style.touchAction = 'pan-y';
4✔
807
                },
808

809
                update: function(event, ui) {
810
                    // cancel if isAllowed returns false
811
                    if (!that.state) {
12✔
812
                        return false;
1✔
813
                    }
814

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

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

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

837
                    if (previousParentPlugin.length) {
10✔
838
                        eventData.previousParentPluginId = that.getId(previousParentPlugin);
3✔
839
                    }
840

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

850
                    // reset placeholder without entries
851
                    that.ui.sortables.each(function() {
10✔
852
                        const element = $(this);
40✔
853

854
                        if (element.children().length === 0) {
40✔
855
                            element.addClass('cms-hidden');
7✔
856
                        }
857
                    });
858

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

871
                    if (placeholder && placeholder.closest('.cms-clipboard-containers').length) {
13✔
872
                        return false;
1✔
873
                    }
874

875
                    // if parent has class disabled, dissalow drop
876
                    if (placeholder && placeholder.parent().hasClass('cms-drag-disabled')) {
12!
877
                        return false;
×
878
                    }
879

880
                    // if parent has class disabled, dissalow drop
881
                    if (placeholder && placeholder.parent().hasClass('cms-draggable-disabled')) {
12✔
882
                        return false;
1✔
883
                    }
884

885
                    const originalItemId = that.getId(originalItem);
11✔
886
                    // save original state events
887
                    const original = $('.cms-draggable-' + originalItemId);
11✔
888

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

904
                    if (placeholderParent && placeholderParent.length) {
9✔
905
                        // placeholderParent is always latest, it maybe that
906
                        // isAllowed is called _before_ placeholder is moved to a child plugin
907
                        plugin = $('.cms-draggable-' + that.getId(placeholderParent.closest('.cms-draggable')));
1✔
908
                    } else {
909
                        plugin = $('.cms-draggable-' + that.getId(placeholder.closest('.cms-draggable')));
8✔
910
                    }
911

912
                    // now set the correct bounds
913
                    // istanbul ignore else
914
                    if (holder.length) {
9✔
915
                        bounds = holder.data('cms').plugin_restriction;
9✔
916
                        immediateParentType = holder.data('cms').plugin_type;
9✔
917
                    }
918
                    if (plugin.length) {
9✔
919
                        bounds = plugin.data('cms').plugin_restriction;
7✔
920
                        immediateParentType = plugin.data('cms').plugin_type;
7✔
921
                    }
922

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

926
                    // check if we have a parent restriction
927
                    if (parent_bounds.length) {
9✔
928
                        that.state = $.inArray(immediateParentType, parent_bounds) !== -1;
2✔
929
                    }
930

931
                    return that.state;
9✔
932
                }
933
            })
934
            .on('cms-structure-update', StructureBoard.actualizePlaceholders);
935
    }
936

937
    _dragRefresh() {
938
        this.ui.sortables.each((i, el) => {
11✔
939
            const element = $(el);
41✔
940

941
            if (element.data('mjsNestedSortable')) {
41!
942
                return;
×
943
            }
944

945
            this._drag(element);
41✔
946
        });
947
    }
948

949
    /**
950
     * @method invalidateState
951
     * @param {String} action - action to handle
952
     * @param {Object} data - data required to handle the object
953
     * @param {Object} opts
954
     * @param {Boolean} [opts.propagate=true] - should we propagate the change to other tabs or not
955
     */
956
    // eslint-disable-next-line complexity
957
    invalidateState(action, data, { propagate = true } = {}) {
50✔
958

959

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

963
        switch (action) {
25✔
964
            case 'COPY': {
965
                this.handleCopyPlugin(data);
2✔
966
                updateNeeded = false; // Copying, however, only changes the clipboard - no update needed
2✔
967
                break;
2✔
968
            }
969

970
            // For other actions, only refresh, if the new state cannot be determined from the data bridge
971
            case 'ADD': {
972
                updateNeeded = this.handleAddPlugin(data);
2✔
973
                break;
2✔
974
            }
975

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

981
            case 'DELETE': {
982
                updateNeeded = this.handleDeletePlugin(data);
2✔
983
                break;
2✔
984
            }
985

986
            case 'CLEAR_PLACEHOLDER': {
987
                updateNeeded = this.handleClearPlaceholder(data);
2✔
988
                break;
2✔
989
            }
990

991
            case 'PASTE':
992
            case 'MOVE': {
993
                updateNeeded = this.handleMovePlugin(data);
4✔
994
                break;
4✔
995
            }
996

997
            case 'CUT': {
998
                updateNeeded = this.handleCutPlugin(data);
2✔
999
                break;
2✔
1000
            }
1001

1002
            case undefined:
1003
            case false:
1004
            case '': {
1005
                CMS.API.Helpers.reloadBrowser();
1✔
1006
                return;
1✔
1007
            }
1008

1009
            default:
1010
                break;
8✔
1011
        }
1012
        Plugin._recalculatePluginPositions(action, data);
24✔
1013

1014
        if (propagate) {
24!
1015
            this._propagateInvalidatedState(action, data);
24✔
1016
        }
1017

1018
        // refresh content mode if needed
1019
        // refresh toolbar
1020
        const currentMode = CMS.settings.mode;
24✔
1021

1022
        if (currentMode === 'structure') {
24✔
1023
            this._requestcontent = null;
22✔
1024

1025
            if (this._loadedContent && updateNeeded) {
22✔
1026
                this.updateContent();
2✔
1027
                return; // Toolbar loaded
2✔
1028
            }
1029
        } else if (updateNeeded === true) {
2!
1030
            this._requestcontent = null;
2✔
1031
            this.updateContent();
2✔
1032
            return; // Toolbar loaded
2✔
1033

1034
        }
1035
        this._loadToolbar()
20✔
1036
            .done(newToolbar => {
1037
                CMS.API.Toolbar._refreshMarkup($(newToolbar).find('.cms-toolbar'));
1✔
1038
            })
1039
            .fail(() => Helpers.reloadBrowser());
1✔
1040
    }
1041

1042
    _propagateInvalidatedState(action, data) {
1043
        this.latestAction = [action, data];
24✔
1044

1045
        ls.set(storageKey, JSON.stringify([action, data, window.location.pathname]));
24✔
1046
    }
1047

1048
    _listenToExternalUpdates() {
1049
        if (!Helpers._isStorageSupported) {
130✔
1050
            return;
3✔
1051
        }
1052

1053
        ls.on(storageKey, this._handleExternalUpdate.bind(this));
127✔
1054
    }
1055

1056
    _handleExternalUpdate(value) {
1057
        // means localstorage was cleared while this page was open
1058
        if (!value) {
×
1059
            return;
×
1060
        }
1061

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

1064
        if (pathname !== window.location.pathname) {
×
1065
            return;
×
1066
        }
1067

1068
        if (isEqual([action, data], this.latestAction)) {
×
1069
            return;
×
1070
        }
1071

1072
        this.invalidateState(action, data, { propagate: false });
×
1073
    }
1074

1075
    updateContent() {
1076
        const loader = $('<div class="cms-content-reloading"></div>');
4✔
1077

1078
        $('.cms-structure').before(loader);
4✔
1079
        return this._requestMode('content')
4✔
1080
            .done(markup => {
1081
                // eslint-disable-next-line no-magic-numbers
1082
                loader.fadeOut(100, () => loader.remove());
2✔
1083
                this.refreshContent(markup);
2✔
1084
            })
1085
            .fail(() => loader.remove() && Helpers.reloadBrowser());
2✔
1086
    }
1087

1088
    _updateSingleContent(content) {
1089
        if (!content.pluginIds || content.pluginIds.length < 1 || content.html === undefined) {
3✔
1090
            // No valid content data available – update needed.
1091
            return true;
1✔
1092
        }
1093

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

1096
        if (nextEl.length < 1 || content.insert) {
2!
1097
            // Plugin not found, but placeholder is known – plugin was added.
1098
            nextEl = this._findNextElement(content.position, content.placeholder_id, content.pluginIds);
2✔
1099
        }
1100

1101
        if (nextEl.length === 0) {
2!
1102
            // Placeholder not found – update needed.
1103
            return true;
×
1104
        }
1105

1106
        nextEl.before(content.html);
2✔
1107

1108
        // Remove previous plugin and related script elements.
1109
        content.pluginIds.forEach(id => {
2✔
1110
            $(`:not(template).cms-plugin.cms-plugin-${id}`).remove();
3✔
1111
            $(`script[data-cms-plugin]#cms-plugin-${id}`).remove();
3✔
1112
        });
1113

1114
        // Update Sekizai blocks.
1115
        this._updateSekizai(content, 'css');
2✔
1116
        this._updateSekizai(content, 'js');
2✔
1117

1118
        return false;
2✔
1119
    }
1120

1121
    _updateContentFromDataBridge(data) {
1122
        if (!data || !data.content || data.content.length === 0) {
16✔
1123
            return true;
12✔
1124
        }
1125
        if (data.source_placeholder_id && !CMS._instances.some(
4✔
1126
            instance => instance.options.type === 'plugin' &&
×
1127
                instance.options.placeholder_id == data.source_placeholder_id // eslint-disable-line eqeqeq
1128
        )) {
1129
            // If last plugin was moved from a placeholder, the placeholder needs to be updated
1130
            return true; // Update needed
1✔
1131
        }
1132

1133
        for (const content of data.content) {
3✔
1134
            if (this._updateSingleContent(content)) {
3✔
1135
                return true; // Early exit if full content update is needed.
1✔
1136
            }
1137
        }
1138
        this._contentChanged(data.messages);
2✔
1139

1140
        if (this.scriptReferenceCount === 0) {
2!
1141
            // No scripts need to be loaded - content update is already done
1142
            StructureBoard._triggerRefreshEvents();
2✔
1143
        }
1144
        return false;
2✔
1145
    }
1146

1147
    _findNextElement(position, placeholder_id, excludedPlugins) {
1148
        let nextEl = $(`div.cms-placeholder.cms-placeholder-${placeholder_id}`);
×
1149
        const nextPlugins = CMS._instances.filter(instance =>
×
1150
            instance.options.type === 'plugin' &&
×
1151
            instance.options.placeholder_id == placeholder_id && // eslint-disable-line eqeqeq
1152
            instance.options.position >= position &&
1153
            !excludedPlugins.includes(1 * instance.options.plugin_id));
1154

1155
        if (nextPlugins.length > 0) {
×
1156
            // Plugins found with higher position, get the one with lowest position
1157
            const nextPluginId = nextPlugins.reduce((acc, instance) => {
×
1158
                return instance.options.position < acc.options.position ? instance : acc;
×
1159
            }, nextPlugins[0]).options.plugin_id;
1160

1161
            nextEl = $(`.cms-plugin.cms-plugin-${nextPluginId}.cms-plugin-start`);
×
1162
        }
1163
        return nextEl;
×
1164
    }
1165

1166
    _updateSekizai(data, block) {
1167
        if ((data[block] || '').length === 0) {
×
1168
            return;
×
1169
        }
1170

1171
        // Find existing candiates, selector and cursor to write to
1172
        let current;
1173
        let selector;
1174
        let location;
1175

1176
        if (block === 'css') {
×
1177
            selector = 'link, style, meta';
×
1178
            current = document.head.querySelectorAll(selector);
×
1179
            location = document.head;
×
1180
        } else if (block === 'js') {
×
1181
            selector = 'script';
×
1182
            current = document.body.querySelectorAll(selector);
×
1183
            location = document.body;
×
1184
        } else {
1185
            return;
×
1186
        }
1187

1188
        // Parse new block in an inert template to avoid executing scripts while building the fragment.
1189
        const template = document.createElement('template');
×
1190

1191
        template.innerHTML = data[block];
×
1192

1193
        // Collect deferred scripts to ensure firing
1194
        this.scriptReferenceCount = 0;
×
1195

1196
        for (const element of template.content.querySelectorAll(selector)) {
×
1197
            if (StructureBoard._elementPresent(current, element)) {
×
1198
                element.remove();
×
1199
            } else if (block === 'js') {
×
1200
                // Recreate script to trigger execution, as browsers don't execute scripts when
1201
                // inserted via innerHTML or cloned via cloneNode.
1202
                const newScript = document.createElement('script');
×
1203

1204
                Array.from(element.attributes).forEach(attr => {
×
1205
                    newScript.setAttribute(attr.name, attr.value);
×
1206
                });
1207

1208
                if (element.src) {
×
1209
                    this.scriptReferenceCount++;
×
1210
                    newScript.async = false;
×
1211
                    newScript.onload = newScript.onerror = this._scriptLoaded.bind(this);
×
1212
                }
1213
                newScript.textContent = element.textContent;
×
1214

1215
                location.appendChild(newScript);
×
1216
            } else {
1217
                location.appendChild(element);
×
1218
            }
1219
        }
1220
        return this.scriptReferenceCount > 0;
×
1221
    }
1222

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

1226
        return $.ajax({
1✔
1227
            url: Helpers.updateUrlWithPath(
1228
                `${CMS.config.request.toolbar}?` +
1229
                    placeholderIds +
1230
                    '&' +
1231
                    `obj_id=${CMS.config.request.pk}&` +
1232
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}` +
1233
                    `&language=${encodeURIComponent(CMS.config.request.language)}`
1234
            )
1235
        });
1236
    }
1237

1238
    // i think this should probably be a separate class at this point that handles all the reloading
1239
    // stuff, it's a bit too much
1240
    // eslint-disable-next-line complexity
1241
    handleMovePlugin(data) {
1242
        if (data.plugin_parent) {
5✔
1243
            if (data.plugin_id) {
1!
1244
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);
1✔
1245

1246
                if (
1!
1247
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1248
                    !draggable.is('.cms-draggable-from-clipboard')
1249
                ) {
1250
                    draggable.remove();
1✔
1251
                }
1252
            }
1253

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

1262
            // external update, have to move the draggable to correct place first
1263
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1264
                const pluginOrder = data.plugin_order || [];
2✔
1265
                const index = pluginOrder.findIndex(
2✔
1266
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
×
1267
                );
1268
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1269

1270
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
1271
                    draggable = draggable.clone();
×
1272
                }
1273

1274
                if (index === 0) {
2!
1275
                    placeholderDraggables.prepend(draggable);
×
1276
                } else if (index !== -1) {
2!
1277
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
×
1278
                }
1279
            }
1280

1281
            // if we _are_ in the correct placeholder we still need to check if the order is correct
1282
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
1283
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1284
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1285
                const actualPluginOrder = this.getIds(
2✔
1286
                    placeholderDraggables.find('> .cms-draggable')
1287
                );
1288

1289
                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
2!
1290
                    // so the plugin order is not correct, means it's an external update and we need to move
1291
                    const pluginOrder = data.plugin_order || [];
2!
1292
                    const index = pluginOrder.findIndex(
2✔
1293
                        pluginId => Number(pluginId) === Number(data.plugin_id)
3✔
1294
                    );
1295

1296
                    if (index === 0) {
2✔
1297
                        placeholderDraggables.prepend(draggable);
1✔
1298
                    } else if (index !== -1) {
1!
1299
                        placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
1✔
1300
                    }
1301
                }
1302
            }
1303

1304
            if (draggable.length) {
4✔
1305
                // empty the children first because replaceWith takes too much time
1306
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1307
                draggable.html('').replaceWith(data.html);
3✔
1308
            } else if (data.target_placeholder_id) {
1!
1309
                // copy from language
1310
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
1✔
1311
            }
1312
        }
1313

1314
        StructureBoard.actualizePlaceholders();
5✔
1315
        Plugin._updateRegistry(data.plugins);
5✔
1316
        data.plugins.forEach(pluginData => {
5✔
1317
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
7✔
1318
        });
1319

1320
        StructureBoard._initializeDragItemsStates();
5✔
1321

1322
        this.ui.sortables = $('.cms-draggables');
5✔
1323
        this._dragRefresh();
5✔
1324
        return this._updateContentFromDataBridge(data);
5✔
1325
    }
1326

1327
    handleCopyPlugin(data) {
1328
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1329
            CMS.API.Clipboard.modal.close();
1✔
1330
        }
1331

1332
        $('.cms-clipboard-containers').html(data.html);
2✔
1333
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1334

1335
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1336

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

1339
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1340
        CMS._plugins.push(pluginData);
2✔
1341
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1342

1343
        CMS.API.Clipboard = new Clipboard();
2✔
1344

1345
        Plugin._updateClipboard();
2✔
1346

1347
        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');
2✔
1348
        const html = clipboardDraggable.parent().html();
2✔
1349

1350
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1351
        CMS.API.Clipboard._enableTriggers();
2✔
1352

1353
        this.ui.sortables = $('.cms-draggables:not(.cms-drag-disabled)');
2✔
1354
        this._dragRefresh();
2✔
1355
        return true; // update needed
2✔
1356
    }
1357

1358
    handleCutPlugin(data) {
1359
        const updateNeededFromDelete = this.handleDeletePlugin(data);
1✔
1360

1361
        this.handleCopyPlugin(data);
1✔
1362
        return updateNeededFromDelete;
1✔
1363
    }
1364

1365
    _extractMessages(doc) {
1366
        let messageList = doc.find('.messagelist');
6✔
1367
        let messages = messageList.find('li');
6✔
1368

1369
        if (!messageList.length || !messages.length) {
6✔
1370
            messageList = doc.find('[data-cms-messages-container]');
5✔
1371
            messages = messageList.find('[data-cms-message]');
5✔
1372
        }
1373

1374
        if (messages.length) {
6✔
1375
            messageList.remove();
3✔
1376

1377
            return messages.toArray().map(el => {
3✔
1378
                const msgEl = $(el);
7✔
1379
                const message = $(el).text().trim();
7✔
1380

1381
                if (message) {
7✔
1382
                    return {
6✔
1383
                        message,
1384
                        error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
10✔
1385
                    };
1386
                }
1387
            }).filter(Boolean);
1388
        }
1389

1390
        return [];
3✔
1391
    }
1392

1393
    refreshContent(contentMarkup) {
1394
        this._requestcontent = null;
3✔
1395
        if (!this._loadedStructure) {
3!
1396
            this._requeststructure = null;
3✔
1397
        }
1398
        const newDoc = new DOMParser().parseFromString(contentMarkup, 'text/html');
3✔
1399

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

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

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

1407
        const messages = this._extractMessages($(newDoc));
3✔
1408

1409
        if (messages.length) {
3✔
1410
            setTimeout(() =>
1✔
1411
                messages.forEach(message => {
1✔
1412
                    CMS.API.Messages.open(message);
1✔
1413
                })
1414
            );
1415
        }
1416
        const headDiff = dd.diff(document.head, nodeToObj(newDoc.head));
3✔
1417

1418
        this._replaceBodyWithHTML(newDoc.body);
3✔
1419
        dd.apply(document.head, headDiff);
3✔
1420

1421
        toolbar.prependTo(document.body);
3✔
1422
        CMS.API.Toolbar._refreshMarkup(newToolbar);
3✔
1423

1424
        $('.cms-structure-content').scrollTop(structureScrollTop);
3✔
1425
        this._loadedContent = true;
3✔
1426
        this._contentChanged();
3✔
1427
    }
1428

1429
    _contentChanged(messages) {
1430
        Plugin._refreshPlugins();
3✔
1431
        if (messages) {
3!
1432
            CMS.API.Messages.close();
×
1433
            if (messages.length) {
×
1434
                CMS.API.Messages.open({
×
1435
                    message: messages.map(message => `<p>${message.message}</p>`).join(''),
×
1436
                    error: messages.some(message => message.level === 'error')
×
1437
                });
1438
            }
1439
        }
1440
    }
1441

1442
    handleAddPlugin(data) {
1443
        if (data.plugin_parent) {
2✔
1444
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1445
        } else {
1446
            // the one in the clipboard is first
1447
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
1✔
1448
        }
1449

1450
        StructureBoard.actualizePlaceholders();
2✔
1451
        Plugin._updateRegistry(data.structure.plugins);
2✔
1452
        data.structure.plugins.forEach(pluginData => {
2✔
1453
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
3✔
1454
        });
1455

1456
        this.ui.sortables = $('.cms-draggables:not(.cms-drag-disabled)');
2✔
1457
        this._dragRefresh();
2✔
1458
        return this._updateContentFromDataBridge(data.structure);
2✔
1459
    }
1460

1461
    handleEditPlugin(data) {
1462
        if (data.plugin_parent) {
2✔
1463
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1464
        } else {
1465
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
1✔
1466
        }
1467

1468
        Plugin._updateRegistry(data.structure.plugins);
2✔
1469

1470
        data.structure.plugins.forEach(pluginData => {
2✔
1471
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
2✔
1472
        });
1473

1474
        this.ui.sortables = $('.cms-draggables:not(.cms-drag-disabled)');
2✔
1475
        this._dragRefresh();
2✔
1476
        return this._updateContentFromDataBridge(data.structure);
2✔
1477
    }
1478

1479
    handleDeletePlugin(data) {
1480
        const { placeholder_id } = CMS._instances.find(
2✔
1481
            // data.plugin_id might be string
1482
            plugin => plugin && plugin.options.plugin_id == data.plugin_id // eslint-disable-line eqeqeq
2✔
1483
        ).options;
1484
        const draggable = $('.cms-draggable-' + data.plugin_id);
2✔
1485
        const children = draggable.find('.cms-draggable');
2✔
1486
        let deletedPluginIds = [data.plugin_id];
2✔
1487
        let parent = draggable.parent().closest('.cms-draggable');
2✔
1488

1489
        if (!parent.length) {
2✔
1490
            parent = draggable.closest('.cms-dragarea');
1✔
1491
        }
1492

1493
        if (children.length) {
2✔
1494
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
1✔
1495
        }
1496

1497
        draggable.remove();
2✔
1498

1499
        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
2✔
1500
        StructureBoard.actualizePlaceholders();
2✔
1501
        const contentData = (data.structure || data); // delete has content in data.structure, cut in data
2✔
1502

1503
        deletedPluginIds.forEach(function(pluginId) {
2✔
1504
            if (!contentData.content) {
3!
1505
                $(`.cms-plugin.cms-plugin-${pluginId}`).remove(); // Remove from content
3✔
1506
            }
1507
            $(`script[data-cms-plugin]#cms-plugin-${pluginId}`).remove(); // Remove script elements
3✔
1508
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
5✔
1509
            remove(
3✔
1510
                CMS._instances,
1511
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
5✔
1512
            );
1513
        });
1514

1515
        const lastPluginDeleted = CMS._instances.find(
2✔
1516
            plugin => plugin.options.placeholder_id == placeholder_id // eslint-disable-line eqeqeq
1✔
1517
        ) === undefined;
1518

1519
        // Additionally always redraw if the last plugin was deleted.
1520
        // The then empty placeholders can render alternative content
1521
        return lastPluginDeleted || this._updateContentFromDataBridge(contentData);
2✔
1522
    }
1523

1524
    handleClearPlaceholder(data) {
1525
        const deletedIds = CMS._instances
1✔
1526
            .filter(instance => {
1527
                if (
3✔
1528
                    instance.options.plugin_id &&
6✔
1529
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
1530
                ) {
1531
                    return true;
2✔
1532
                }
1533
            })
1534
            .map(instance => instance.options.plugin_id);
2✔
1535

1536
        deletedIds.forEach(id => {
1✔
1537
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
5✔
1538
            remove(
2✔
1539
                CMS._instances,
1540
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
5✔
1541
            );
1542

1543
            $(`.cms-draggable-${id}`).remove();
2✔
1544
        });
1545

1546
        StructureBoard.actualizePlaceholders();
1✔
1547
        return true;
1✔
1548
    }
1549

1550
    /**
1551
     * Similar to CMS.Plugin populates globally required
1552
     * variables, that only need querying once, e.g. placeholders.
1553
     *
1554
     * @method _initializeGlobalHandlers
1555
     * @static
1556
     * @private
1557
     */
1558
    static _initializeGlobalHandlers() {
1559
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
77✔
1560
    }
1561

1562
    /**
1563
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
1564
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
1565
     *
1566
     * @function actualizePlaceholders
1567
     * @private
1568
     */
1569
    static actualizePlaceholders() {
1570
        placeholders.each(function() {
160✔
1571
            const placeholder = $(this);
477✔
1572
            const copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');
477✔
1573

1574
            if (
477✔
1575
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
1576
                    .length
1577
            ) {
1578
                placeholder.removeClass('cms-dragarea-empty');
159✔
1579
                copyAll.removeClass('cms-submenu-item-disabled');
159✔
1580
                copyAll.find('> a').removeAttr('aria-disabled');
159✔
1581
            } else {
1582
                placeholder.addClass('cms-dragarea-empty');
318✔
1583
                copyAll.addClass('cms-submenu-item-disabled');
318✔
1584
                copyAll.find('> a').attr('aria-disabled', 'true');
318✔
1585
            }
1586
        });
1587

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

1590
        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
160!
1591
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
×
1592
        }
1593
    }
1594

1595
    /**
1596
     * actualizePluginCollapseStatus
1597
     *
1598
     * @public
1599
     * @param {String} pluginId open the plugin if it should be open
1600
     */
1601
    static actualizePluginCollapseStatus(pluginId) {
1602
        const el = $(`.cms-draggable-${pluginId}`);
1✔
1603
        const open = (CMS.settings.states || []).find(openPluginId => Number(openPluginId) === Number(pluginId));
1!
1604

1605
        // only add this class to elements which have a draggable area
1606
        // istanbul ignore else
1607
        if (open && el.find('> .cms-draggables').length) {
1✔
1608
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1✔
1609
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1✔
1610
        }
1611
    }
1612

1613
    /**
1614
     * @function actualizePluginsCollapsibleStatus
1615
     * @private
1616
     * @param {jQuery} els lists of plugins (.cms-draggables)
1617
     */
1618
    static actualizePluginsCollapsibleStatus(els) {
1619
        els.each(function() {
9✔
1620
            const childList = $(this);
14✔
1621
            const pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');
14✔
1622

1623
            if (childList.children().length) {
14✔
1624
                pluginDragItem.addClass('cms-dragitem-collapsable');
10✔
1625
                if (childList.children().is(':visible')) {
10!
1626
                    pluginDragItem.addClass('cms-dragitem-expanded');
10✔
1627
                }
1628
            } else {
1629
                pluginDragItem.removeClass('cms-dragitem-collapsable');
4✔
1630
            }
1631
        });
1632
    }
1633

1634
    /**
1635
     * Replaces the current document body with the provided HTML content.
1636
     *
1637
     * This method removes all existing script elements from the document body,
1638
     * replaces the body content with the new HTML, and then re-inserts new script
1639
     * elements to ensure they are executed.
1640
     *
1641
     * @param {HTMLElement} body - The new HTML content to replace the current body.
1642
     *
1643
     * @private
1644
     */
1645
    _replaceBodyWithHTML(body) {
1646
        // Remove (i.e. detach) old scripts
1647
        const oldScripts = document.body.querySelectorAll('script:not([type="application/json"])');
×
1648

1649
        oldScripts.forEach(script => script.remove());
×
1650

1651
        // Replace the body content
1652
        document.body.innerHTML = body.innerHTML;
×
1653

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

1657
        this._processNewScripts(newScripts, oldScripts);
×
1658

1659
        if (this.scriptReferenceCount === 0) {
×
1660
            StructureBoard._triggerRefreshEvents();
×
1661
        }
1662
    }
1663

1664
    /**
1665
     * Processes new script elements by comparing them with old script elements.
1666
     * If a new script is not present in the old scripts, it rewrites the script to the DOM to force execution.
1667
     *
1668
     * @param {NodeList} newScripts - A list of new script elements to be processed.
1669
     * @param {NodeList} oldScripts - A list of old script elements to compare against.
1670
     * @private
1671
     */
1672
    _processNewScripts(newScripts, oldScripts) {
1673
        newScripts.forEach(script => {
1✔
1674
            if (!StructureBoard._elementPresent(oldScripts, script)) {
27!
1675
                // Rewrite script to DOM to force execution
1676
                const newScript = document.createElement('script');
×
1677

1678
                // Copy attributes
1679
                Array.from(script.attributes).forEach(attr => {
×
1680
                    newScript.setAttribute(attr.name, attr.value);
×
1681
                });
1682
                if (script.src) {
×
1683
                    // Needs to be loaded from a server
1684
                    this.scriptReferenceCount++;
×
1685
                    newScript.onload = newScript.onerror = this._scriptLoaded.bind(this);
×
1686
                } else {
1687
                    // Inline script
1688
                    newScript.textContent = script.textContent;
×
1689
                }
1690
                script.parentNode.insertBefore(newScript, script.nextSibling);
×
1691
                script.remove();
×
1692
            }
1693
        });
1694
    }
1695

1696
    /**
1697
     * Checks if a given element is present in the current set of elements.
1698
     *
1699
     * @param {NodeList} current - The current set of elements to check against.
1700
     * @param {Element} element - The element to check for presence.
1701
     * @returns {boolean} - Returns true if the element is present in the current set, otherwise false.
1702
     * @private
1703
     */
1704
    static _elementPresent(current, element) {
1705
        const markup = element.outerHTML;
27✔
1706

1707
        return [...current].some(el => el.outerHTML === markup);
378✔
1708
    }
1709

1710
    /**
1711
     * Handles the event when a script is loaded.
1712
     * Decrements the script reference count and, if it reaches zero,
1713
     * dispatches a 'load' event on the window and triggers a 'cms-content-refresh' event.
1714
     *
1715
     * @private
1716
     */
1717
    _scriptLoaded() {
1718
        if (--this.scriptReferenceCount < 1) {
×
1719
            StructureBoard._triggerRefreshEvents();
×
1720
        }
1721
    }
1722

1723
    /**
1724
     * Triggers refresh events on the window and document.
1725
     *
1726
     * This method dispatches the 'DOMContentLoaded' event on the document,
1727
     * the 'load' event on the window, and triggers the 'cms-content-refresh'
1728
     * event using jQuery on the window. The events are dispatched asynchronously
1729
     * to ensure that the current execution context is completed before the events
1730
     * are triggered.
1731
     *
1732
     * @private
1733
     * @static
1734
     */
1735
    static _triggerRefreshEvents() {
1736
        setTimeout(() => {
3✔
1737
            Helpers._getWindow().document.dispatchEvent(new Event('DOMContentLoaded'));
3✔
1738
            Helpers._getWindow().dispatchEvent(new Event('load'));
3✔
1739
            Helpers._getWindow().dispatchEvent(new Event('cms-content-refresh'));
3✔
1740
        }, 0);
1741
    }
1742

1743
    highlightPluginFromUrl() {
1744
        const hash = window.location.hash;
120✔
1745
        const regex = /cms-plugin-(\d+)/;
120✔
1746

1747
        if (!hash || !hash.match(regex)) {
120!
1748
            return;
120✔
1749
        }
1750

1751
        const pluginId = regex.exec(hash)[1];
×
1752

1753
        if (this._loadedContent) {
×
1754
            Plugin._highlightPluginContent(pluginId, {
×
1755
                seeThrough: true,
1756
                prominent: true,
1757
                delay: 3000
1758
            });
1759
        }
1760
    }
1761

1762
    /**
1763
     * Get's plugins data from markup
1764
     *
1765
     * @method _getPluginDataFromMarkup
1766
     * @private
1767
     * @param {Node} body
1768
     * @param {Array<Number | String>} pluginIds
1769
     * @returns {Array<[String, Object]>}
1770
     */
1771
    static _getPluginDataFromMarkup(body, pluginIds) {
1772
        return pluginIds.map(pluginId => {
9✔
1773
            const pluginData = body.querySelector(`#cms-plugin-${pluginId}`);
15✔
1774
            let settings;
1775

1776
            if (pluginData) {
15✔
1777
                try {
5✔
1778
                    settings = JSON.parse(pluginData.textContent);
5✔
1779
                } catch {
1780
                    settings = false;
2✔
1781
                }
1782
            } else {
1783
                settings = false;
10✔
1784
            }
1785

1786
            return settings;
15✔
1787
        }).filter(Boolean);
1788
    }
1789

1790
}
1791

1792
/**
1793
 * Initializes the collapsed/expanded states of dragitems in structureboard.
1794
 *
1795
 * @method _initializeDragItemsStates
1796
 * @static
1797
 * @private
1798
 */
1799
// istanbul ignore next
1800
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
1801
    // removing duplicate entries
1802
    'use strict';
1803

1804
    const states = CMS.settings.states || [];
1805
    const sortedArr = states.sort();
1806
    const filteredArray = [];
1807

1808
    for (let i = 0; i < sortedArr.length; i++) {
1809
        if (sortedArr[i] !== sortedArr[i + 1]) {
1810
            filteredArray.push(sortedArr[i]);
1811
        }
1812
    }
1813
    CMS.settings.states = filteredArray;
1814

1815
    // loop through the items
1816
    $.each(CMS.settings.states, function(index, id) {
1817
        const el = $('.cms-draggable-' + id);
1818

1819
        // only add this class to elements which have immediate children
1820
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
1821
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1822
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1823
        }
1824
    });
1825
};
1826

1827
// shorthand for jQuery(document).ready();
1828
$(StructureBoard._initializeGlobalHandlers);
1✔
1829

1830
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

© 2026 Coveralls, Inc