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

divio / django-cms / #29891

26 Aug 2025 01:24AM UTC coverage: 75.132% (+0.07%) from 75.059%
#29891

push

travis-ci

web-flow
Merge 09573ad01 into de255061d

1079 of 1626 branches covered (66.36%)

2568 of 3418 relevant lines covered (75.13%)

26.22 hits per line

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

86.18
/cms/static/cms/js/modules/cms.structureboard.js
1
/*
2
 * Copyright https://github.com/divio/django-cms
3
 */
4

5
/* eslint-env es6 */
6
/* jshint esversion: 6 */
7
/* global CMS */
8

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

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

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

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

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

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

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

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

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

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

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

69
        dd = new DiffDOM();
130✔
70

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

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

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

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

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

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

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

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

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

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

148
        // check if modes should be visible
149
        if (this.ui.dragareas.not('.cms-clipboard .cms-dragarea').length || this.ui.placeholders.length) {
107✔
150
            // eslint-disable-line
151
            this.ui.toolbarModeSwitcher.find('.cms-btn').removeClass('cms-btn-disabled');
105✔
152
        }
153

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

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

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

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

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

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

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

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

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

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

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

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

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

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

279
        if (options.useHoveredPlugin && CMS.settings.mode !== 'structure') {
4✔
280
            that._showAndHighlightPlugin(options.successTimeout).then($.noop, $.noop);
1✔
281
        } else if (!options.useHoveredPlugin) {
3✔
282
            // eslint-disable-next-line no-lonely-if
283
            if (CMS.settings.mode === 'structure') {
2✔
284
                that.hide();
1✔
285
            } else if (CMS.settings.mode === 'edit') {
1!
286
                /* istanbul ignore else */ that.show();
1✔
287
            }
288
        }
289
    }
290

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

411
                Plugin._updateRegistry(pluginData);
1✔
412

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

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

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

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

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

444
    _requestMode(mode) {
445
        let url;
446

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

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

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

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

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

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

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

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

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

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

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

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

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

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

527
                Plugin._refreshPlugins();
1✔
528

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

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

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

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

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

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

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

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

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

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

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

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

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

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

610
        return id;
65✔
611
    }
612

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1111
        return false;
2✔
1112
    }
1113

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

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

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

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

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

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

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

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

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

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

1186
        dd.apply(newElements, diff);
×
1187

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1303
        StructureBoard._initializeDragItemsStates();
5✔
1304

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

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

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

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

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

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

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

1328
        Plugin._updateClipboard();
2✔
1329

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

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

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

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

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

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

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

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

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

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

1375
        return [];
3✔
1376
    }
1377

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1482
        draggable.remove();
2✔
1483

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1775
}
1776

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

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

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

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

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

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

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

© 2025 Coveralls, Inc