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

divio / django-cms / #30167

24 Nov 2025 12:49PM UTC coverage: 89.964% (+14.8%) from 75.132%
#30167

push

travis-ci

web-flow
Merge 565a22220 into 5f080488d

1337 of 2146 branches covered (62.3%)

9206 of 10233 relevant lines covered (89.96%)

11.2 hits per line

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

86.14
/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'), // 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').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');
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
                const toolbar = $('.cms').add('[data-cms]').detach();
1✔
500
                const title = head.filter('title');
1✔
501
                const bodyElement = $('body');
1✔
502

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

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

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

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

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

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

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

532
                Plugin._refreshPlugins();
1✔
533

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

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

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

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

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

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

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

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

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

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

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

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

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

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

615
        return id;
65✔
616
    }
617

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

957

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1116
        return false;
2✔
1117
    }
1118

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

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

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

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

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

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

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

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

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

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

1191
        dd.apply(newElements, diff);
×
1192

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1307
        StructureBoard._initializeDragItemsStates();
5✔
1308

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

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

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

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

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

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

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

1332
        Plugin._updateClipboard();
2✔
1333

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

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

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

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

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

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

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

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

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

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

1377
        return [];
3✔
1378
    }
1379

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1484
        draggable.remove();
2✔
1485

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1771
            return settings;
15✔
1772
        }).filter(Boolean);
1773
    }
1774

1775
}
1776

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

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

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

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

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

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

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

© 2025 Coveralls, Inc