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

divio / django-cms / #29485

02 Mar 2025 02:41PM UTC coverage: 75.059%. Remained the same
#29485

push

travis-ci

web-flow
Merge 8624a5499 into e331372d9

1072 of 1625 branches covered (65.97%)

5 of 5 new or added lines in 1 file covered. (100.0%)

39 existing lines in 1 file now uncovered.

2564 of 3416 relevant lines covered (75.06%)

26.29 hits per line

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

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

5
/* eslint-env es6 */
6
/* jshint esversion: 6 */
7
/* 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 = $(bodyRegex.exec(contentMarkup)[1]);
1✔
399

400
                const structure = body.find('.cms-structure-content');
1✔
401
                const toolbar = body.find('.cms-toolbar');
1✔
402
                const scripts = body.filter(function() {
1✔
403
                    return $(this).is('[type="text/cms-template"]'); // cms scripts
1✔
404
                });
405
                const pluginIds = this.getIds(body.find('.cms-draggable'));
1✔
406
                const pluginDataSource = body.filter('script[data-cms]').toArray()
1✔
407
                    .map(script => script.textContent || '').join();
×
408
                const pluginData = StructureBoard._getPluginDataFromMarkup(
1✔
409
                    pluginDataSource,
410
                    pluginIds
411
                );
412

413
                Plugin._updateRegistry(pluginData.map(([, data]) => data));
1✔
414

415
                CMS.API.Toolbar._refreshMarkup(toolbar);
1✔
416

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

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

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

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

446
    _requestMode(mode) {
447
        let url;
448

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

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

462
                return markup;
3✔
463
            });
464
        }
465

466
        return this[`_request${mode}`];
4✔
467
    }
468

469
    _loadContent() {
470
        const that = this;
47✔
471

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

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

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

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

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

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

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

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

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

529
                Plugin._refreshPlugins();
1✔
530

531
                const scripts = $('script');
1✔
532

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

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

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

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

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

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

571
        // set active item
572
        const modes = this.ui.toolbarModeLinks;
48✔
573

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

577
        CMS.settings.mode = 'edit';
48✔
578

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

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

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

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

612
        return id;
65✔
613
    }
614

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

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

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

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

646
        this.ui.container.show();
104✔
647
        hideLoader();
104✔
648

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

931
            if (element.data('mjsNestedSortable')) {
41!
932
                return;
×
933
            }
934

935
            this._drag(element);
41✔
936
        });
937
    }
938

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1043
        ls.on(storageKey, this._handleExternalUpdate.bind(this));
127✔
1044
    }
1045

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

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

1054
        if (pathname !== window.location.pathname) {
×
1055
            return;
×
1056
        }
1057

1058
        if (isEqual([action, data], this.latestAction)) {
×
1059
            return;
×
1060
        }
1061

1062
        this.invalidateState(action, data, { propagate: false });
×
1063
    }
1064

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

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

1078
    _updateContentFromDataBridge(data) {  // eslint-disable-line complexity
1079
        if (!data || !data.content || !data.content.pluginIds ||
15✔
1080
            data.content.pluginIds.length < 1 || data.content.html === undefined) {
1081
            // No content data available in data bridge? Full content upudate needed.
1082
            return true;  // Update needed
12✔
1083
        }
1084
        if (data.source_placeholder_id && !CMS._instances.some(
3✔
1085
                instance => instance.options.type === 'plugin' &&
×
1086
                instance.options.placeholder_id == data.source_placeholder_id  // eslint-disable-line eqeqeq
1087
        )) {
1088
            // If last plugin was moved from a placeholder, the placeholder needs to be updated
1089
            return true;  // Update needed
1✔
1090
        }
1091

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

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

1099
            nextEl = this._findNextElement(position, placeholder_id, data.content.pluginIds);
2✔
1100
        }
1101
        if (nextEl.length === 0) {
2!
1102
            // Placeholder not found - update needed
1103
            return true;
×
1104
        }
1105
        nextEl.before(data.content.html);
2✔
1106

1107
        // Delete previous content
1108
        // Go through all plugins and child plugins (they might not be nested)
1109
        data.content.pluginIds.forEach(id => {
2✔
1110
            $(`:not(template).cms-plugin.cms-plugin-${id}`).remove();
3✔
1111
            // Plugin data is updated through the data bridge - script elements can be removed
1112
            $(`script[data-cms-plugin]#cms-plugin-${id}`).remove();
3✔
1113
        });
1114
        this._contentChanged(data.messages);
2✔
1115
        this._updateSekizai(data, 'css');
2✔
1116
        if (!this._updateSekizai(data, 'js')) {
2!
1117
            // No scripts need to be loaded - content update is done
1118
            StructureBoard._triggerRefreshEvents();
2✔
1119
        }
1120
        return false;
2✔
1121
    }
1122

1123
    _findNextElement(position, placeholder_id, excludedPlugins) {
1124
        let nextEl = $(`div.cms-placeholder.cms-placeholder-${placeholder_id}`);
×
1125
        const nextPlugins = CMS._instances.filter(instance =>
×
UNCOV
1126
            instance.options.type === 'plugin' &&
×
1127
            instance.options.placeholder_id == placeholder_id &&  // eslint-disable-line eqeqeq
1128
            instance.options.position >= position &&
1129
            !excludedPlugins.includes(1 * instance.options.plugin_id));
1130

UNCOV
1131
        if (nextPlugins.length > 0) {
×
1132
            // Plugins found with higher position, get the one with lowest position
1133
            const nextPluginId = nextPlugins.reduce((acc, instance) => {
×
UNCOV
1134
                return instance.options.position < acc.options.position ? instance : acc;
×
1135
            }, nextPlugins[0]).options.plugin_id;
1136

UNCOV
1137
            nextEl = $(`.cms-plugin.cms-plugin-${nextPluginId}.cms-plugin-start`);
×
1138
        }
UNCOV
1139
        return nextEl;
×
1140
    }
1141

1142
    _updateSekizai(data, block) {
1143
        if ((data.content[block] || '').length === 0) {
×
UNCOV
1144
            return;
×
1145
        }
1146

1147
        // Find existing candiates, selector and cursor to write to
1148
        let current;
1149
        let selector;
1150
        let location;
1151

1152
        if (block === 'css') {
×
1153
            selector = 'link, style, meta';
×
1154
            current = document.head.querySelectorAll(selector);
×
1155
            location = document.head;
×
1156
        } else if (block === 'js') {
×
1157
            selector = 'script';
×
1158
            current = document.body.querySelectorAll(selector);
×
UNCOV
1159
            location = document.body;
×
1160
        } else {
UNCOV
1161
            return;
×
1162
        }
1163

1164
        // Parse new block, by creating the diff
1165
        // Cannot use innerHTML since this would prevent scripts to be executed.
1166
        const newElements = document.createElement('div');
×
UNCOV
1167
        const diff = dd.diff(newElements, `<div>${data.content[block]}</div>`);
×
1168

UNCOV
1169
        dd.apply(newElements, diff);
×
1170

1171
        // Collect deferred scripts to ensure firing
UNCOV
1172
        this.scriptReferenceCount = 0;
×
1173

1174
        for (const element of newElements.querySelectorAll(selector)) {
×
1175
            if (StructureBoard._elementPresent(current, element)) {
×
UNCOV
1176
                element.remove();
×
1177
            } else {
1178
                if (element.hasAttribute('src')) {
×
1179
                    this.scriptReferenceCount++;
×
UNCOV
1180
                    element.onload = element.onerror = this._scriptLoaded.bind(this);
×
1181
                }
UNCOV
1182
                location.appendChild(element);
×
1183
            }
1184
        }
UNCOV
1185
        return this.scriptReferenceCount > 0;
×
1186
    }
1187

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

1191
        return $.ajax({
1✔
1192
            url: Helpers.updateUrlWithPath(
1193
                `${CMS.config.request.toolbar}?` +
1194
                    placeholderIds +
1195
                    '&' +
1196
                    `obj_id=${CMS.config.request.pk}&` +
1197
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}`
1198
            )
1199
        });
1200
    }
1201

1202
    // i think this should probably be a separate class at this point that handles all the reloading
1203
    // stuff, it's a bit too much
1204
    // eslint-disable-next-line complexity
1205
    handleMovePlugin(data) {
1206
        if (data.plugin_parent) {
5✔
1207
            if (data.plugin_id) {
1!
1208
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);
1✔
1209

1210
                if (
1!
1211
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1212
                    !draggable.is('.cms-draggable-from-clipboard')
1213
                ) {
1214
                    draggable.remove();
1✔
1215
                }
1216
            }
1217

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

1226
            // external update, have to move the draggable to correct place first
1227
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1228
                const pluginOrder = data.plugin_order;
2✔
1229
                const index = findIndex(
2✔
1230
                    pluginOrder,
UNCOV
1231
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
×
1232
                );
1233
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1234

1235
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
UNCOV
1236
                    draggable = draggable.clone();
×
1237
                }
1238

1239
                if (index === 0) {
2!
UNCOV
1240
                    placeholderDraggables.prepend(draggable);
×
1241
                } else if (index !== -1) {
2!
UNCOV
1242
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
×
1243
                }
1244
            }
1245

1246
            // if we _are_ in the correct placeholder we still need to check if the order is correct
1247
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
1248
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1249
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1250
                const actualPluginOrder = this.getIds(
2✔
1251
                    placeholderDraggables.find('> .cms-draggable')
1252
                );
1253

1254
                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
2!
1255
                    // so the plugin order is not correct, means it's an external update and we need to move
1256
                    const pluginOrder = data.plugin_order;
2✔
1257
                    const index = findIndex(
2✔
1258
                        pluginOrder,
1259
                        pluginId => Number(pluginId) === Number(data.plugin_id)
3✔
1260
                    );
1261

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

1270
            if (draggable.length) {
4✔
1271
                // empty the children first because replaceWith takes too much time
1272
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1273
                draggable.html('').replaceWith(data.html);
3✔
1274
            } else if (data.target_placeholder_id) {
1!
1275
                // copy from language
1276
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
1✔
1277
            }
1278
        }
1279

1280
        StructureBoard.actualizePlaceholders();
5✔
1281
        Plugin._updateRegistry(data.plugins);
5✔
1282
        data.plugins.forEach(pluginData => {
5✔
1283
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
7✔
1284
        });
1285

1286
        StructureBoard._initializeDragItemsStates();
5✔
1287

1288
        this.ui.sortables = $('.cms-draggables');
5✔
1289
        this._dragRefresh();
5✔
1290
        return this._updateContentFromDataBridge(data);
5✔
1291
    }
1292

1293
    handleCopyPlugin(data) {
1294
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1295
            CMS.API.Clipboard.modal.close();
1✔
1296
        }
1297

1298
        $('.cms-clipboard-containers').html(data.html);
2✔
1299
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1300

1301
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1302

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

1305
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1306
        CMS._plugins.push(pluginData);
2✔
1307
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1308

1309
        CMS.API.Clipboard = new Clipboard();
2✔
1310

1311
        Plugin._updateClipboard();
2✔
1312

1313
        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');
2✔
1314
        const html = clipboardDraggable.parent().html();
2✔
1315

1316
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1317
        CMS.API.Clipboard._enableTriggers();
2✔
1318

1319
        this.ui.sortables = $('.cms-draggables');
2✔
1320
        this._dragRefresh();
2✔
1321
        return true;  // update needed
2✔
1322
    }
1323

1324
    handleCutPlugin(data) {
1325
        const updateNeededFromDelete = this.handleDeletePlugin(data);
1✔
1326

1327
        this.handleCopyPlugin(data);
1✔
1328
        return updateNeededFromDelete;
1✔
1329
    }
1330

1331
    _extractMessages(doc) {
1332
        let messageList = doc.find('.messagelist');
6✔
1333
        let messages = messageList.find('li');
6✔
1334

1335
        if (!messageList.length || !messages.length) {
6✔
1336
            messageList = doc.find('[data-cms-messages-container]');
5✔
1337
            messages = messageList.find('[data-cms-message]');
5✔
1338
        }
1339

1340
        if (messages.length) {
6✔
1341
            messageList.remove();
3✔
1342

1343
            return compact(
3✔
1344
                messages.toArray().map(el => {
1345
                    const msgEl = $(el);
7✔
1346
                    const message = $(el).text().trim();
7✔
1347

1348
                    if (message) {
7✔
1349
                        return {
6✔
1350
                            message,
1351
                            error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
10✔
1352
                        };
1353
                    }
1354
                })
1355
            );
1356
        }
1357

1358
        return [];
3✔
1359
    }
1360

1361
    refreshContent(contentMarkup) {
1362
        this._requestcontent = null;
3✔
1363
        if (!this._loadedStructure) {
3!
1364
            this._requeststructure = null;
3✔
1365
        }
1366
        const newDoc = new DOMParser().parseFromString(contentMarkup, 'text/html');
3✔
1367

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

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

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

1375
        const messages = this._extractMessages($(newDoc));
3✔
1376

1377
        if (messages.length) {
3✔
1378
            setTimeout(() =>
1✔
1379
                messages.forEach(message => {
1✔
1380
                    CMS.API.Messages.open(message);
1✔
1381
                })
1382
            );
1383
        }
1384
        const headDiff = dd.diff(document.head, nodeToObj(newDoc.head));
3✔
1385

1386
        this._replaceBodyWithHTML(newDoc.body);
3✔
1387
        dd.apply(document.head, headDiff);
3✔
1388

1389
        toolbar.prependTo(document.body);
3✔
1390
        CMS.API.Toolbar._refreshMarkup(newToolbar);
3✔
1391

1392
        $('.cms-structure-content').scrollTop(structureScrollTop);
3✔
1393
        this._loadedContent = true;
3✔
1394
        this._contentChanged();
3✔
1395
    }
1396

1397
    _contentChanged(messages) {
1398
        Plugin._refreshPlugins();
3✔
1399
        if (messages) {
3!
1400
            CMS.API.Messages.close();
×
1401
            if (messages.length) {
×
1402
                CMS.API.Messages.open({
×
1403
                    message: messages.map(message => `<p>${message.message}</p>`).join(''),
×
UNCOV
1404
                    error: messages.some(message => message.level === 'error')
×
1405
                });
1406
            }
1407
        }
1408
    }
1409

1410
    handleAddPlugin(data) {
1411
        if (data.plugin_parent) {
2✔
1412
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1413
        } else {
1414
            // the one in the clipboard is first
1415
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
1✔
1416
        }
1417

1418
        StructureBoard.actualizePlaceholders();
2✔
1419
        Plugin._updateRegistry(data.structure.plugins);
2✔
1420
        data.structure.plugins.forEach(pluginData => {
2✔
1421
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
3✔
1422
        });
1423

1424
        this.ui.sortables = $('.cms-draggables');
2✔
1425
        this._dragRefresh();
2✔
1426
        return this._updateContentFromDataBridge(data.structure);
2✔
1427
    }
1428

1429
    handleEditPlugin(data) {
1430
        if (data.plugin_parent) {
2✔
1431
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1432
        } else {
1433
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
1✔
1434
        }
1435

1436
        Plugin._updateRegistry(data.structure.plugins);
2✔
1437

1438
        data.structure.plugins.forEach(pluginData => {
2✔
1439
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
2✔
1440
        });
1441

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

1447
    handleDeletePlugin(data) {
1448
        const placeholder_id = CMS._instances.find(
2✔
1449
            plugin => plugin.options.plugin_id === data.plugin_id
2✔
1450
        ).options.placeholder_id;
1451
        const draggable = $('.cms-draggable-' + data.plugin_id);
2✔
1452
        const children = draggable.find('.cms-draggable');
2✔
1453
        let deletedPluginIds = [data.plugin_id];
2✔
1454
        let parent = draggable.parent().closest('.cms-draggable');
2✔
1455

1456
        if (!parent.length) {
2✔
1457
            parent = draggable.closest('.cms-dragarea');
1✔
1458
        }
1459

1460
        if (children.length) {
2✔
1461
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
1✔
1462
        }
1463

1464
        draggable.remove();
2✔
1465

1466
        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
2✔
1467
        StructureBoard.actualizePlaceholders();
2✔
1468
        deletedPluginIds.forEach(function(pluginId) {
2✔
1469
            $(`.cms-plugin.cms-plugin-${pluginId}`).remove();  // Remove from content
3✔
1470
            $(`script[data-cms-plugin]#cms-plugin-${pluginId}`).remove();  // Remove script elements
3✔
1471
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
5✔
1472
            remove(
3✔
1473
                CMS._instances,
1474
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
5✔
1475
            );
1476
        });
1477
        const lastPluginDeleted = CMS._instances.find(
2✔
1478
            plugin => plugin.options.placeholder_id == placeholder_id  // eslint-disable-line eqeqeq
1✔
1479
        ) === undefined;
1480

1481
        return lastPluginDeleted;
2✔
1482
    }
1483

1484
    handleClearPlaceholder(data) {
1485
        const deletedIds = CMS._instances
1✔
1486
            .filter(instance => {
1487
                if (
3✔
1488
                    instance.options.plugin_id &&
6✔
1489
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
1490
                ) {
1491
                    return true;
2✔
1492
                }
1493
            })
1494
            .map(instance => instance.options.plugin_id);
2✔
1495

1496
        deletedIds.forEach(id => {
1✔
1497
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
5✔
1498
            remove(
2✔
1499
                CMS._instances,
1500
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
5✔
1501
            );
1502

1503
            $(`.cms-draggable-${id}`).remove();
2✔
1504
        });
1505

1506
        StructureBoard.actualizePlaceholders();
1✔
1507
        return true;
1✔
1508
    }
1509

1510
    /**
1511
     * Similar to CMS.Plugin populates globally required
1512
     * variables, that only need querying once, e.g. placeholders.
1513
     *
1514
     * @method _initializeGlobalHandlers
1515
     * @static
1516
     * @private
1517
     */
1518
    static _initializeGlobalHandlers() {
1519
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
77✔
1520
    }
1521

1522
    /**
1523
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
1524
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
1525
     *
1526
     * @function actualizePlaceholders
1527
     * @private
1528
     */
1529
    static actualizePlaceholders() {
1530
        placeholders.each(function() {
160✔
1531
            const placeholder = $(this);
477✔
1532
            const copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');
477✔
1533

1534
            if (
477✔
1535
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
1536
                    .length
1537
            ) {
1538
                placeholder.removeClass('cms-dragarea-empty');
159✔
1539
                copyAll.removeClass('cms-submenu-item-disabled');
159✔
1540
                copyAll.find('> a').removeAttr('aria-disabled');
159✔
1541
            } else {
1542
                placeholder.addClass('cms-dragarea-empty');
318✔
1543
                copyAll.addClass('cms-submenu-item-disabled');
318✔
1544
                copyAll.find('> a').attr('aria-disabled', 'true');
318✔
1545
            }
1546
        });
1547

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

1550
        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
160!
UNCOV
1551
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
×
1552
        }
1553
    }
1554

1555
    /**
1556
     * actualizePluginCollapseStatus
1557
     *
1558
     * @public
1559
     * @param {String} pluginId open the plugin if it should be open
1560
     */
1561
    static actualizePluginCollapseStatus(pluginId) {
1562
        const el = $(`.cms-draggable-${pluginId}`);
1✔
1563
        const open = find(CMS.settings.states, openPluginId => Number(openPluginId) === Number(pluginId));
1✔
1564

1565
        // only add this class to elements which have a draggable area
1566
        // istanbul ignore else
1567
        if (open && el.find('> .cms-draggables').length) {
1✔
1568
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1✔
1569
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1✔
1570
        }
1571
    }
1572

1573
    /**
1574
     * @function actualizePluginsCollapsibleStatus
1575
     * @private
1576
     * @param {jQuery} els lists of plugins (.cms-draggables)
1577
     */
1578
    static actualizePluginsCollapsibleStatus(els) {
1579
        els.each(function() {
9✔
1580
            const childList = $(this);
14✔
1581
            const pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');
14✔
1582

1583
            if (childList.children().length) {
14✔
1584
                pluginDragItem.addClass('cms-dragitem-collapsable');
10✔
1585
                if (childList.children().is(':visible')) {
10!
1586
                    pluginDragItem.addClass('cms-dragitem-expanded');
10✔
1587
                }
1588
            } else {
1589
                pluginDragItem.removeClass('cms-dragitem-collapsable');
4✔
1590
            }
1591
        });
1592
    }
1593

1594
    /**
1595
     * Replaces the current document body with the provided HTML content.
1596
     *
1597
     * This method removes all existing script elements from the document body,
1598
     * replaces the body content with the new HTML, and then re-inserts new script
1599
     * elements to ensure they are executed.
1600
     *
1601
     * @param {HTMLElement} body - The new HTML content to replace the current body.
1602
     *
1603
     * @private
1604
     */
1605
    _replaceBodyWithHTML(body) {
1606
        // Remove (i.e. detach) old scripts
UNCOV
1607
        const oldScripts = document.body.querySelectorAll('script:not([type="application/json"])');
×
1608

UNCOV
1609
        oldScripts.forEach(script => script.remove());
×
1610

1611
        // Replace the body content
UNCOV
1612
        document.body.innerHTML = body.innerHTML;
×
1613

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

UNCOV
1617
        this._processNewScripts(newScripts, oldScripts);
×
1618

1619
        if (this.scriptReferenceCount === 0) {
×
UNCOV
1620
            StructureBoard._triggerRefreshEvents();
×
1621
        }
1622
    }
1623

1624
    /**
1625
     * Processes new script elements by comparing them with old script elements.
1626
     * If a new script is not present in the old scripts, it rewrites the script to the DOM to force execution.
1627
     *
1628
     * @param {NodeList} newScripts - A list of new script elements to be processed.
1629
     * @param {NodeList} oldScripts - A list of old script elements to compare against.
1630
     * @private
1631
     */
1632
    _processNewScripts(newScripts, oldScripts) {
1633
        newScripts.forEach(script => {
×
UNCOV
1634
            if (!StructureBoard._elementPresent(oldScripts, script)) {
×
1635
                // Rewrite script to DOM to force execution
UNCOV
1636
                const newScript = document.createElement('script');
×
1637

1638
                // Copy attributes
1639
                Array.from(script.attributes).forEach(attr => {
×
UNCOV
1640
                    newScript.setAttribute(attr.name, attr.value);
×
1641
                });
UNCOV
1642
                if (script.src) {
×
1643
                    // Needs to be loaded from a server
1644
                    this.scriptReferenceCount++;
×
UNCOV
1645
                    newScript.onload = newScript.onerror = this._scriptLoaded.bind(this);
×
1646
                } else {
1647
                    // Inline script
UNCOV
1648
                    newScript.textContent = script.textContent;
×
1649
                }
1650
                script.parentNode.insertBefore(newScript, script.nextSibling);
×
UNCOV
1651
                script.remove();
×
1652
            }
1653
        });
1654
    }
1655

1656
    /**
1657
     * Checks if a given element is present in the current set of elements.
1658
     *
1659
     * @param {NodeList} current - The current set of elements to check against.
1660
     * @param {Element} element - The element to check for presence.
1661
     * @returns {boolean} - Returns true if the element is present in the current set, otherwise false.
1662
     * @private
1663
     */
1664
    static _elementPresent(current, element) {
UNCOV
1665
        const markup = element.outerHTML;
×
1666

UNCOV
1667
        return [...current].some(el => el.outerHTML === markup);
×
1668
    }
1669

1670
    /**
1671
     * Handles the event when a script is loaded.
1672
     * Decrements the script reference count and, if it reaches zero,
1673
     * dispatches a 'load' event on the window and triggers a 'cms-content-refresh' event.
1674
     *
1675
     * @private
1676
     */
1677
    _scriptLoaded() {
1678
        if (--this.scriptReferenceCount < 1) {
×
UNCOV
1679
            StructureBoard._triggerRefreshEvents();
×
1680
        }
1681
    }
1682

1683
    /**
1684
     * Triggers refresh events on the window and document.
1685
     *
1686
     * This method dispatches the 'DOMContentLoaded' event on the document,
1687
     * the 'load' event on the window, and triggers the 'cms-content-refresh'
1688
     * event using jQuery on the window. The events are dispatched asynchronously
1689
     * to ensure that the current execution context is completed before the events
1690
     * are triggered.
1691
     *
1692
     * @private
1693
     * @static
1694
     */
1695
    static _triggerRefreshEvents() {
1696
        setTimeout(() => {
2✔
1697
            Helpers._getWindow().document.dispatchEvent(new Event('DOMContentLoaded'));
2✔
1698
            Helpers._getWindow().dispatchEvent(new Event('load'));
2✔
1699
            $(Helpers._getWindow()).trigger('cms-content-refresh');
2✔
1700
        }, 0);
1701
    }
1702

1703
    highlightPluginFromUrl() {
1704
        const hash = window.location.hash;
120✔
1705
        const regex = /cms-plugin-(\d+)/;
120✔
1706

1707
        if (!hash || !hash.match(regex)) {
120!
1708
            return;
120✔
1709
        }
1710

UNCOV
1711
        const pluginId = regex.exec(hash)[1];
×
1712

UNCOV
1713
        if (this._loadedContent) {
×
1714
            Plugin._highlightPluginContent(pluginId, {
×
1715
                seeThrough: true,
1716
                prominent: true,
1717
                delay: 3000
1718
            });
1719
        }
1720
    }
1721

1722
    /**
1723
     * Get's plugins data from markup
1724
     *
1725
     * @method _getPluginDataFromMarkup
1726
     * @private
1727
     * @param {String} markup
1728
     * @param {Array<Number | String>} pluginIds
1729
     * @returns {Array<[String, Object]>}
1730
     */
1731
    static _getPluginDataFromMarkup(markup, pluginIds) {
1732
        return compact(
9✔
1733
            pluginIds.map(pluginId => {
1734
                // oh boy
1735
                const regex = new RegExp(`CMS._plugins.push\\((\\["cms\-plugin\-${pluginId}",[\\s\\S]*?\\])\\)`, 'g');
15✔
1736
                const matches = regex.exec(markup);
15✔
1737
                let settings;
1738

1739
                if (matches) {
15✔
1740
                    try {
5✔
1741
                        settings = JSON.parse(matches[1]);
5✔
1742
                    } catch (e) {
1743
                        settings = false;
2✔
1744
                    }
1745
                } else {
1746
                    settings = false;
10✔
1747
                }
1748

1749
                return settings;
15✔
1750
            })
1751
        );
1752
    }
1753

1754
}
1755

1756
/**
1757
 * Initializes the collapsed/expanded states of dragitems in structureboard.
1758
 *
1759
 * @method _initializeDragItemsStates
1760
 * @static
1761
 * @private
1762
 */
1763
// istanbul ignore next
1764
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
1765
    // removing duplicate entries
1766
    'use strict';
1767

1768
    const states = CMS.settings.states || [];
1769
    const sortedArr = states.sort();
1770
    const filteredArray = [];
1771

1772
    for (let i = 0; i < sortedArr.length; i++) {
1773
        if (sortedArr[i] !== sortedArr[i + 1]) {
1774
            filteredArray.push(sortedArr[i]);
1775
        }
1776
    }
1777
    CMS.settings.states = filteredArray;
1778

1779
    // loop through the items
1780
    $.each(CMS.settings.states, function(index, id) {
1781
        const el = $('.cms-draggable-' + id);
1782

1783
        // only add this class to elements which have immediate children
1784
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
1785
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1786
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1787
        }
1788
    });
1789
};
1790

1791
// shorthand for jQuery(document).ready();
1792
$(StructureBoard._initializeGlobalHandlers);
1✔
1793

1794
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