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

divio / django-cms / #30103

12 Nov 2025 03:42PM UTC coverage: 90.532%. Remained the same
#30103

push

travis-ci

web-flow
Merge 80b4ee016 into c38b75715

1306 of 2044 branches covered (63.89%)

294 of 321 new or added lines in 13 files covered. (91.59%)

458 existing lines in 7 files now uncovered.

9151 of 10108 relevant lines covered (90.53%)

11.16 hits per line

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

86.16
/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
const DOMParser = window.DOMParser; // needed only for testing
1✔
31
const storageKey = 'cms-structure';
1✔
32

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

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

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

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

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

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

70
        dd = new DiffDOM();
130✔
71

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

283
        if (options.useHoveredPlugin && CMS.settings.mode !== 'structure') {
4✔
284
            that._showAndHighlightPlugin(options.successTimeout).then($.noop, $.noop);
1✔
285
        } else if (!options.useHoveredPlugin) {
3✔
286

287
            if (CMS.settings.mode === 'structure') {
2✔
288
                that.hide();
1✔
289
            } else if (CMS.settings.mode === 'edit') {
1!
290
                /* istanbul ignore else */ that.show();
1✔
291
            }
292
        }
293
    }
294

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

415
                Plugin._updateRegistry(pluginData);
1✔
416

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

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

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

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

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

448
    _requestMode(mode) {
449
        let url;
450

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

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

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

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

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

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

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

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

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

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

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

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

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

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

531
                Plugin._refreshPlugins();
1✔
532

533
                const scripts = $('script');
1✔
534

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

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

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

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

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

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

573
        // set active item
574
        const modes = this.ui.toolbarModeLinks;
48✔
575

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

579
        CMS.settings.mode = 'edit';
48✔
580

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

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

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

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

614
        return id;
65✔
615
    }
616

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

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

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

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

648
        this.ui.container.show();
104✔
649
        hideLoader();
104✔
650

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

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

659
        this.ui.window.trigger('resize');
104✔
660
    }
661

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

928
                    return that.state;
9✔
929
                }
930
            })
931
            .on('cms-structure-update', StructureBoard.actualizePlaceholders);
932
    }
933

934
    _dragRefresh() {
935
        this.ui.sortables.each((i, el) => {
11✔
936
            const element = $(el);
41✔
937

938
            if (element.data('mjsNestedSortable')) {
41!
UNCOV
939
                return;
×
940
            }
941

942
            this._drag(element);
41✔
943
        });
944
    }
945

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

956

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

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

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

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

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

983
            case 'CLEAR_PLACEHOLDER': {
984
                updateNeeded = this.handleClearPlaceholder(data);
2✔
985
                break;
2✔
986
            }
987

988
            case 'PASTE':
989
            case 'MOVE': {
990
                updateNeeded = this.handleMovePlugin(data);
4✔
991
                break;
4✔
992
            }
993

994
            case 'CUT': {
995
                updateNeeded = this.handleCutPlugin(data);
2✔
996
                break;
2✔
997
            }
998

999
            case undefined:
1000
            case false:
1001
            case '': {
1002
                CMS.API.Helpers.reloadBrowser();
1✔
1003
                return;
1✔
1004
            }
1005

1006
            default:
1007
                break;
8✔
1008
        }
1009
        Plugin._recalculatePluginPositions(action, data);
24✔
1010

1011
        if (propagate) {
24!
1012
            this._propagateInvalidatedState(action, data);
24✔
1013
        }
1014

1015
        // refresh content mode if needed
1016
        // refresh toolbar
1017
        const currentMode = CMS.settings.mode;
24✔
1018

1019
        if (currentMode === 'structure') {
24✔
1020
            this._requestcontent = null;
22✔
1021

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

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

1039
    _propagateInvalidatedState(action, data) {
1040
        this.latestAction = [action, data];
24✔
1041

1042
        ls.set(storageKey, JSON.stringify([action, data, window.location.pathname]));
24✔
1043
    }
1044

1045
    _listenToExternalUpdates() {
1046
        if (!Helpers._isStorageSupported) {
130✔
1047
            return;
3✔
1048
        }
1049

1050
        ls.on(storageKey, this._handleExternalUpdate.bind(this));
127✔
1051
    }
1052

1053
    _handleExternalUpdate(value) {
1054
        // means localstorage was cleared while this page was open
UNCOV
1055
        if (!value) {
×
UNCOV
1056
            return;
×
1057
        }
1058

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

UNCOV
1061
        if (pathname !== window.location.pathname) {
×
UNCOV
1062
            return;
×
1063
        }
1064

1065
        if (isEqual([action, data], this.latestAction)) {
×
1066
            return;
×
1067
        }
1068

1069
        this.invalidateState(action, data, { propagate: false });
×
1070
    }
1071

1072
    updateContent() {
1073
        const loader = $('<div class="cms-content-reloading"></div>');
4✔
1074

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

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

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

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

1098
        if (nextEl.length === 0) {
2!
1099
            // Placeholder not found – update needed.
UNCOV
1100
            return true;
×
1101
        }
1102

1103
        nextEl.before(content.html);
2✔
1104

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

1111
        // Update Sekizai blocks.
1112
        this._updateSekizai(content, 'css');
2✔
1113
        this._updateSekizai(content, 'js');
2✔
1114

1115
        return false;
2✔
1116
    }
1117

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

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

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

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

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

1158
            nextEl = $(`.cms-plugin.cms-plugin-${nextPluginId}.cms-plugin-start`);
×
1159
        }
UNCOV
1160
        return nextEl;
×
1161
    }
1162

1163
    _updateSekizai(data, block) {
1164
        if ((data[block] || '').length === 0) {
×
UNCOV
1165
            return;
×
1166
        }
1167

1168
        // Find existing candiates, selector and cursor to write to
1169
        let current;
1170
        let selector;
1171
        let location;
1172

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

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

UNCOV
1190
        dd.apply(newElements, diff);
×
1191

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

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

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

1212
        return $.ajax({
1✔
1213
            url: Helpers.updateUrlWithPath(
1214
                `${CMS.config.request.toolbar}?` +
1215
                    placeholderIds +
1216
                    '&' +
1217
                    `obj_id=${CMS.config.request.pk}&` +
1218
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}` +
1219
                    `&language=${encodeURIComponent(CMS.config.request.language)}`
1220
            )
1221
        });
1222
    }
1223

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

1232
                if (
1!
1233
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1234
                    !draggable.is('.cms-draggable-from-clipboard')
1235
                ) {
1236
                    draggable.remove();
1✔
1237
                }
1238
            }
1239

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

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

1256
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
UNCOV
1257
                    draggable = draggable.clone();
×
1258
                }
1259

1260
                if (index === 0) {
2!
UNCOV
1261
                    placeholderDraggables.prepend(draggable);
×
1262
                } else if (index !== -1) {
2!
UNCOV
1263
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
×
1264
                }
1265
            }
1266

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

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

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

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

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

1306
        StructureBoard._initializeDragItemsStates();
5✔
1307

1308
        this.ui.sortables = $('.cms-draggables');
5✔
1309
        this._dragRefresh();
5✔
1310
        return this._updateContentFromDataBridge(data);
5✔
1311
    }
1312

1313
    handleCopyPlugin(data) {
1314
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1315
            CMS.API.Clipboard.modal.close();
1✔
1316
        }
1317

1318
        $('.cms-clipboard-containers').html(data.html);
2✔
1319
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1320

1321
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1322

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

1325
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1326
        CMS._plugins.push(pluginData);
2✔
1327
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1328

1329
        CMS.API.Clipboard = new Clipboard();
2✔
1330

1331
        Plugin._updateClipboard();
2✔
1332

1333
        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');
2✔
1334
        const html = clipboardDraggable.parent().html();
2✔
1335

1336
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1337
        CMS.API.Clipboard._enableTriggers();
2✔
1338

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

1344
    handleCutPlugin(data) {
1345
        const updateNeededFromDelete = this.handleDeletePlugin(data);
1✔
1346

1347
        this.handleCopyPlugin(data);
1✔
1348
        return updateNeededFromDelete;
1✔
1349
    }
1350

1351
    _extractMessages(doc) {
1352
        let messageList = doc.find('.messagelist');
6✔
1353
        let messages = messageList.find('li');
6✔
1354

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

1360
        if (messages.length) {
6✔
1361
            messageList.remove();
3✔
1362

1363
            return messages.toArray().map(el => {
3✔
1364
                const msgEl = $(el);
7✔
1365
                const message = $(el).text().trim();
7✔
1366

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

1376
        return [];
3✔
1377
    }
1378

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1483
        draggable.remove();
2✔
1484

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1641
        this._processNewScripts(newScripts, oldScripts);
×
1642

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

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

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

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

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

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

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

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

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

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

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

1746
    /**
1747
     * Get's plugins data from markup
1748
     *
1749
     * @method _getPluginDataFromMarkup
1750
     * @private
1751
     * @param {Node} body
1752
     * @param {Array<Number | String>} pluginIds
1753
     * @returns {Array<[String, Object]>}
1754
     */
1755
    static _getPluginDataFromMarkup(body, pluginIds) {
1756
        return pluginIds.map(pluginId => {
9✔
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
        }).filter(Boolean);
1772
    }
1773

1774
}
1775

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

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

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

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

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

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

1814
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