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

divio / django-cms / #28765

02 May 2024 10:30AM UTC coverage: 77.16% (-0.2%) from 77.399%
#28765

push

travis-ci

web-flow
Merge branch 'django-cms:release/3.11.x' into release/3.11.x

1079 of 1564 branches covered (68.99%)

22 of 40 new or added lines in 4 files covered. (55.0%)

8 existing lines in 2 files now uncovered.

2554 of 3310 relevant lines covered (77.16%)

32.74 hits per line

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

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

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

16
import './jquery.ui.custom';
17
import './jquery.ui.touchpunch';
18
import './jquery.ui.nestedsortable';
19

20
import measureScrollbar from './scrollbar';
21
import preloadImagesFromMarkup from './preload-images';
22

23
import { Helpers, KEYS } from './cms.base';
24
import { showLoader, hideLoader } from './loader';
25

26
let dd;
27
const DOMParser = window.DOMParser; // needed only for testing
1✔
28
const storageKey = 'cms-structure';
1✔
29

30
let placeholders;
31
let originalPluginContainer;
32

33
const triggerWindowResize = () => {
1✔
34
    try {
49✔
35
        var evt = document.createEvent('UIEvents');
49✔
36

37
        evt.initUIEvent('resize', true, false, window, 0);
49✔
38
        window.dispatchEvent(evt);
49✔
39
    } catch (e) {}
40
};
41

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

44
/**
45
 * Handles drag & drop, mode switching and collapsables.
46
 *
47
 * @class StructureBoard
48
 * @namespace CMS
49
 */
50
class StructureBoard {
51
    constructor() {
52
        // elements
53
        this._setupUI();
126✔
54

55
        // states
56
        this.click = 'click.cms.structure';
126✔
57
        this.keyUpAndDown = 'keyup.cms.structure keydown.cms.structure';
126✔
58
        this.pointerUp = 'pointerup.cms';
126✔
59
        this.state = false;
126✔
60
        this.dragging = false;
126✔
61
        this.latestAction = [];
126✔
62
        ls.remove(storageKey);
126✔
63

64
        dd = new DiffDOM();
126✔
65

66
        // setup initial stuff
67
        const setup = this._setup();
126✔
68

69
        // istanbul ignore if
70
        if (typeof setup === 'undefined' && CMS.config.mode === 'draft') {
126✔
71
            this._preloadOppositeMode();
72
        }
73
        this._setupModeSwitcher();
126✔
74
        this._events();
126✔
75
        StructureBoard.actualizePlaceholders();
126✔
76

77
        setTimeout(() => this.highlightPluginFromUrl(), 0);
126✔
78
        this._listenToExternalUpdates();
126✔
79
    }
80

81
    /**
82
     * Stores all jQuery references within `this.ui`.
83
     *
84
     * @method _setupUI
85
     * @private
86
     */
87
    _setupUI() {
88
        var container = $('.cms-structure');
126✔
89
        var toolbar = $('.cms-toolbar');
126✔
90

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

108
        this._preventScroll = new PreventParentScroll(this.ui.content[0]);
126✔
109
    }
110

111
    /**
112
     * Initial setup (and early bail if specific
113
     * elements do not exist).
114
     *
115
     * @method _setup
116
     * @private
117
     * @returns {Boolean|void}
118
     */
119
    _setup() {
120
        var that = this;
126✔
121

122
        // cancel if there is no structure / content switcher
123
        if (!this.ui.toolbarModeSwitcher.length) {
126✔
124
            return false;
19✔
125
        }
126

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

138
        if (CMS.config.settings.legacy_mode) {
107✔
139
            that._loadedStructure = true;
1✔
140
            that._loadedContent = true;
1✔
141
        }
142

143
        // check if modes should be visible
144
        if (this.ui.dragareas.not('.cms-clipboard .cms-dragarea').length || this.ui.placeholders.length) {
107✔
145
            // eslint-disable-line
146
            this.ui.toolbarModeSwitcher.find('.cms-btn').removeClass('cms-btn-disabled');
105✔
147
        }
148

149
        // add drag & drop functionality
150
        // istanbul ignore next
151
        $('.cms-draggable').one(
152
            'pointerover.cms.drag',
153
            once(() => {
154
                $('.cms-draggable').off('pointerover.cms.drag');
155
                this._drag();
156
            })
157
        );
158
    }
159

160
    _preloadOppositeMode() {
161
        if (CMS.config.settings.legacy_mode) {
3✔
162
            return;
1✔
163
        }
164
        const WAIT_BEFORE_PRELOADING = 2000;
2✔
165

166
        $(Helpers._getWindow()).one('load', () => {
2✔
167
            setTimeout(() => {
2✔
168
                if (this._loadedStructure) {
2✔
169
                    this._requestMode('content');
1✔
170
                } else {
171
                    this._requestMode('structure');
1✔
172
                }
173
            }, WAIT_BEFORE_PRELOADING);
174
        });
175
    }
176

177
    _events() {
178
        this.ui.window.on('resize.cms.structureboard', () => {
126✔
179
            if (!this._loadedContent) {
8,853✔
180
                return;
3,034✔
181
            }
182
            const width = this.ui.window[0].innerWidth;
5,819✔
183
            const BREAKPOINT = 1024;
5,819✔
184

185
            if (width > BREAKPOINT && !this.condensed) {
5,819✔
186
                this._makeCondensed();
50✔
187
            }
188

189
            if (width <= BREAKPOINT && this.condensed) {
5,819!
UNCOV
190
                this._makeFullWidth();
×
191
            }
192
        });
193
    }
194

195
    /**
196
     * Sets up events handlers for switching
197
     * structureboard modes.
198
     *
199
     * @method _setupModeSwitcher
200
     * @private
201
     */
202
    _setupModeSwitcher() {
203
        const modes = this.ui.toolbarModeLinks;
126✔
204
        let cmdPressed;
205

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

225
        // show edit mode
226
        modes.on(this.click, e => {
126✔
227
            e.preventDefault();
4✔
228
            e.stopImmediatePropagation();
4✔
229

230
            if (modes.hasClass('cms-btn-disabled')) {
4!
231
                return;
×
232
            }
233

234
            if (cmdPressed && e.type === 'click') {
4!
235
                // control the behaviour when ctrl/cmd is pressed
236
                Helpers._getWindow().open(modes.attr('href'), '_blank');
×
237
                return;
×
238
            }
239

240
            if (CMS.settings.mode === 'edit') {
4✔
241
                this.show();
2✔
242
            } else {
243
                this.hide();
2✔
244
            }
245
        });
246

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

265
    /**
266
     * @method _toggleStructureBoard
267
     * @private
268
     * @param {Object} [options] options
269
     * @param {Boolean} [options.useHoveredPlugin] should the plugin be taken into account
270
     */
271
    _toggleStructureBoard(options = {}) {
2✔
272
        var that = this;
4✔
273

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

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

302
        if (!CMS.API.Tooltip) {
3✔
303
            return Promise.resolve(false);
1✔
304
        }
305

306
        var tooltip = CMS.API.Tooltip.domElem;
2✔
307
        var HIGHLIGHT_TIMEOUT = 10;
2✔
308
        var DRAGGABLE_HEIGHT = 50; // it's not precisely 50, but it fits
2✔
309

310
        if (!tooltip.is(':visible')) {
2✔
311
            return Promise.resolve(false);
1✔
312
        }
313

314
        var pluginId = tooltip.data('plugin_id');
1✔
315

316
        return this.show({ saveState: false }).then(function() {
1✔
317
            var draggable = $('.cms-draggable-' + pluginId);
1✔
318
            var doc = $(document);
1✔
319
            var currentExpandmode = doc.data('expandmode');
1✔
320

321
            // expand necessary parents
322
            doc.data('expandmode', false);
1✔
323
            draggable
1✔
324
                .parents('.cms-draggable')
325
                .find('> .cms-dragitem-collapsable:not(".cms-dragitem-expanded") > .cms-dragitem-text')
326
                .each((i, el) => $(el).triggerHandler(Plugin.click));
×
327

328
            setTimeout(() => doc.data('expandmode', currentExpandmode));
1✔
329
            setTimeout(function() {
1✔
330
                var offsetParent = draggable.offsetParent();
1✔
331
                var position = draggable.position().top + offsetParent.scrollTop();
1✔
332

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

335
                Plugin._highlightPluginStructure(draggable.find('.cms-dragitem:first'), { successTimeout, seeThrough });
1✔
336
            }, HIGHLIGHT_TIMEOUT);
337
        });
338
    }
339

340
    /**
341
     * Shows the structureboard. (Structure mode)
342
     *
343
     * @method show
344
     * @public
345
     * @param {Boolean} init true if this is first initialization
346
     * @returns {Promise}
347
     */
348
    show({ init = false, saveState = true } = {}) {
193✔
349
        // cancel show if live modus is active
350
        if (CMS.config.mode === 'live') {
105✔
351
            return Promise.resolve(false);
1✔
352
        }
353

354
        // in order to get consistent positioning
355
        // of the toolbar we have to know if the page
356
        // had the scrollbar and if it had - we adjust
357
        // the toolbar positioning
358
        if (init) {
104✔
359
            var width = this.ui.toolbar.width();
61✔
360
            var scrollBarWidth = this.ui.window[0].innerWidth - width;
61✔
361

362
            if (!scrollBarWidth && init) {
61!
363
                scrollBarWidth = measureScrollbar();
61✔
364
            }
365

366
            if (scrollBarWidth) {
61!
367
                this.ui.toolbar.css('right', scrollBarWidth);
61✔
368
            }
369
        }
370
        // apply new settings
371
        CMS.settings.mode = 'structure';
104✔
372
        Helpers.setSettings(CMS.settings);
104✔
373

374
        if (!init && saveState) {
104✔
375
            this._saveStateInURL();
43✔
376
        }
377

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

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

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

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

396
                var bodyRegex = /<body[\S\s]*?>([\S\s]*)<\/body>/gi;
1✔
397
                var body = $(bodyRegex.exec(contentMarkup)[1]);
1✔
398

399
                var structure = body.find('.cms-structure-content');
1✔
400
                var toolbar = body.find('.cms-toolbar');
1✔
401
                var scripts = body.filter(function() {
1✔
402
                    var elem = $(this);
1✔
403

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

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

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

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

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

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

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

449
    _requestMode(mode) {
450
        var url = new URI(window.location.href);
4✔
451

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

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

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

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

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

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

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

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

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

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

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

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

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

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

532
                Plugin._refreshPlugins();
1✔
533

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

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

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

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

551
                that._loadedContent = true;
1✔
552
            })
553
            .fail(function() {
554
                window.location.href = new URI(window.location.href)
×
555
                    .removeSearch(CMS.config.settings.structure)
556
                    .toString();
557
            });
558
    }
559

560
    _saveStateInURL() {
561
        var url = new URI(window.location.href);
47✔
562

563
        url[CMS.settings.mode === 'structure' ? 'addSearch' : 'removeSearch'](CMS.config.settings.structure);
47✔
564

565
        history.replaceState({}, '', url.toString());
47✔
566
    }
567

568
    /**
569
     * Hides the structureboard. (Content mode)
570
     *
571
     * @method hide
572
     * @param {Boolean} init true if this is first initialization
573
     * @returns {Boolean|void}
574
     */
575
    hide(init) {
576
        // cancel show if live modus is active
577
        if (CMS.config.mode === 'live') {
49✔
578
            return false;
1✔
579
        }
580

581
        // reset toolbar positioning
582
        this.ui.toolbar.css('right', '');
48✔
583
        $('html').removeClass('cms-overflow');
48✔
584

585
        // set active item
586
        var modes = this.ui.toolbarModeLinks;
48✔
587

588
        modes.removeClass('cms-btn-active').eq(1).addClass('cms-btn-active');
48✔
589
        this.ui.html.removeClass('cms-structure-mode-structure').addClass('cms-structure-mode-content');
48✔
590

591
        CMS.settings.mode = 'edit';
48✔
592
        if (!init) {
48✔
593
            this._saveStateInURL();
4✔
594
        }
595

596
        // hide canvas
597
        return this._loadContent().then(this._hideBoard.bind(this));
48✔
598
    }
599

600
    /**
601
     * Gets the id of the element.
602
     * relies on cms-{item}-{id} to always be second in a string of classes (!)
603
     *
604
     * @method getId
605
     * @param {jQuery} el element to get id from
606
     * @returns {String}
607
     */
608
    getId(el) {
609
        // cancel if no element is defined
610
        if (el === undefined || el === null || el.length <= 0) {
73✔
611
            return false;
7✔
612
        }
613

614
        var id = null;
66✔
615
        var cls = el.attr('class').split(' ')[1];
66✔
616

617
        if (el.hasClass('cms-plugin')) {
66✔
618
            id = cls.replace('cms-plugin-', '').trim();
10✔
619
        } else if (el.hasClass('cms-draggable')) {
56✔
620
            id = cls.replace('cms-draggable-', '').trim();
36✔
621
        } else if (el.hasClass('cms-placeholder')) {
20✔
622
            id = cls.replace('cms-placeholder-', '').trim();
2✔
623
        } else if (el.hasClass('cms-dragbar')) {
18✔
624
            id = cls.replace('cms-dragbar-', '').trim();
2✔
625
        } else if (el.hasClass('cms-dragarea')) {
16✔
626
            id = cls.replace('cms-dragarea-', '').trim();
11✔
627
        }
628

629
        return id;
65✔
630
    }
631

632
    /**
633
     * Gets the ids of the list of  elements.
634
     *
635
     * @method getIds
636
     * @param {jQuery} els elements to get id from
637
     * @returns {String[]}
638
     */
639
    getIds(els) {
640
        var that = this;
8✔
641
        var array = [];
8✔
642

643
        els.each(function() {
8✔
644
            array.push(that.getId($(this)));
13✔
645
        });
646
        return array;
8✔
647
    }
648

649
    /**
650
     * Actually shows the board canvas.
651
     *
652
     * @method _showBoard
653
     * @param {Boolean} init init
654
     * @private
655
     */
656
    _showBoard(init) {
657
        // set active item
658
        var modes = this.ui.toolbarModeLinks;
104✔
659

660
        modes.removeClass('cms-btn-active').eq(0).addClass('cms-btn-active');
104✔
661
        this.ui.html.removeClass('cms-structure-mode-content').addClass('cms-structure-mode-structure');
104✔
662

663
        this.ui.container.show();
104✔
664
        hideLoader();
104✔
665

666
        if (!init) {
104✔
667
            this._makeCondensed();
43✔
668
        }
669

670
        if (init && !this._loadedContent) {
104✔
671
            this._makeFullWidth();
61✔
672
        }
673

674
        this._preventScroll.start();
104✔
675
        this.ui.window.trigger('resize');
104✔
676
    }
677

678
    _makeCondensed() {
679
        this.condensed = true;
93✔
680
        this.ui.container.addClass('cms-structure-condensed');
93✔
681
        var url = new URI(window.location.href);
93✔
682

683
        url.removeSearch('structure');
93✔
684

685
        if (CMS.settings.mode === 'structure') {
93✔
686
            history.replaceState({}, '', url.toString());
46✔
687
        }
688

689
        var width = this.ui.toolbar.width();
93✔
690
        var scrollBarWidth = this.ui.window[0].innerWidth - width;
93✔
691

692
        if (!scrollBarWidth) {
93✔
693
            scrollBarWidth = measureScrollbar();
44✔
694
        }
695

696
        this.ui.html.removeClass('cms-overflow');
93✔
697

698
        if (scrollBarWidth) {
93!
699
            // this.ui.toolbar.css('right', scrollBarWidth);
700
            this.ui.container.css('right', -scrollBarWidth);
93✔
701
        }
702
    }
703

704
    _makeFullWidth() {
705
        this.condensed = false;
61✔
706
        this.ui.container.removeClass('cms-structure-condensed');
61✔
707
        var url = new URI(window.location.href);
61✔
708

709
        url.addSearch('structure');
61✔
710

711
        if (CMS.settings.mode === 'structure') {
61!
712
            history.replaceState({}, '', url.toString());
61✔
713
            this.ui.html.addClass('cms-overflow');
61✔
714
        }
715

716
        this.ui.container.css('right', 0);
61✔
717
    }
718

719
    /**
720
     * Hides the board canvas.
721
     *
722
     * @method _hideBoard
723
     * @private
724
     */
725
    _hideBoard() {
726
        // hide elements
727
        this.ui.container.hide();
48✔
728
        this._preventScroll.stop();
48✔
729

730
        // this is sometimes required for user-side scripts to
731
        // render dynamic elements on the page correctly.
732
        // e.g. you have a parallax script that calculates position
733
        // of elements based on document height. but if the page is
734
        // loaded with structureboard active - the document height
735
        // would be same as screen height, which is likely incorrect,
736
        // so triggering resize on window would force user scripts
737
        // to recalculate whatever is required there
738
        // istanbul ignore catch
739
        triggerWindowResize();
48✔
740
    }
741

742
    /**
743
     * Sets up all the sortables.
744
     *
745
     * @method _drag
746
     * @param {jQuery} [elem=this.ui.sortables] which element to initialize
747
     * @private
748
     */
749
    _drag(elem = this.ui.sortables) {
36✔
750
        var that = this;
36✔
751

752
        elem
36✔
753
            .nestedSortable({
754
                items: '> .cms-draggable:not(.cms-draggable-disabled .cms-draggable)',
755
                placeholder: 'cms-droppable',
756
                connectWith: '.cms-draggables:not(.cms-hidden)',
757
                tolerance: 'intersect',
758
                toleranceElement: '> div',
759
                dropOnEmpty: true,
760
                // cloning huge structure is a performance loss compared to cloning just a dragitem
761
                helper: function createHelper(e, item) {
762
                    var clone = item.find('> .cms-dragitem').clone();
8✔
763

764
                    clone.wrap('<div class="' + item[0].className + '"></div>');
8✔
765
                    return clone.parent();
8✔
766
                },
767
                appendTo: '.cms-structure-content',
768
                // appendTo: '.cms',
769
                cursor: 'move',
770
                cursorAt: { left: -15, top: -15 },
771
                opacity: 1,
772
                zIndex: 9999999,
773
                delay: 100,
774
                tabSize: 15,
775
                // nestedSortable
776
                listType: 'div.cms-draggables',
777
                doNotClear: true,
778
                disableNestingClass: 'cms-draggable-disabled',
779
                errorClass: 'cms-draggable-disallowed',
780
                scrollSpeed: 15,
781
                // eslint-disable-next-line no-magic-numbers
782
                scrollSensitivity: that.ui.window.height() * 0.2,
783
                start: function(e, ui) {
784
                    that.ui.content.attr('data-touch-action', 'none');
20✔
785

786
                    originalPluginContainer = ui.item.closest('.cms-draggables');
20✔
787

788
                    that.dragging = true;
20✔
789
                    // show empty
790
                    StructureBoard.actualizePlaceholders();
20✔
791
                    // ensure all menus are closed
792
                    Plugin._hideSettingsMenu();
20✔
793
                    // keep in mind that caching cms-draggables query only works
794
                    // as long as we don't create them on the fly
795
                    that.ui.sortables.each(function() {
20✔
796
                        var element = $(this);
80✔
797

798
                        if (element.children().length === 0) {
80✔
799
                            element.removeClass('cms-hidden');
18✔
800
                        }
801
                    });
802

803
                    // fixes placeholder height
804
                    ui.item.addClass('cms-is-dragging');
20✔
805
                    ui.helper.addClass('cms-draggable-is-dragging');
20✔
806
                    if (ui.item.find('> .cms-draggables').children().length) {
20✔
807
                        ui.helper.addClass('cms-draggable-stack');
1✔
808
                    }
809

810
                    // attach escape event to cancel dragging
811
                    that.ui.doc.on('keyup.cms.interrupt', function(event, cancel) {
20✔
812
                        if ((event.keyCode === KEYS.ESC && that.dragging) || cancel) {
3✔
813
                            that.state = false;
2✔
814
                            $.ui.sortable.prototype._mouseStop();
2✔
815
                            that.ui.sortables.trigger('mouseup');
2✔
816
                        }
817
                    });
818
                },
819

820
                beforeStop: function(event, ui) {
821
                    that.dragging = false;
4✔
822
                    ui.item.removeClass('cms-is-dragging cms-draggable-stack');
4✔
823
                    that.ui.doc.off('keyup.cms.interrupt');
4✔
824
                    that.ui.content.attr('data-touch-action', 'pan-y');
4✔
825
                },
826

827
                update: function(event, ui) {
828
                    // cancel if isAllowed returns false
829
                    if (!that.state) {
12✔
830
                        return false;
1✔
831
                    }
832

833
                    var newPluginContainer = ui.item.closest('.cms-draggables');
11✔
834

835
                    if (originalPluginContainer.is(newPluginContainer)) {
11✔
836
                        // if we moved inside same container,
837
                        // but event is fired on a parent, discard update
838
                        if (!newPluginContainer.is(this)) {
2✔
839
                            return false;
1✔
840
                        }
841
                    } else {
842
                        StructureBoard.actualizePluginsCollapsibleStatus(
9✔
843
                            newPluginContainer.add(originalPluginContainer)
844
                        );
845
                    }
846

847
                    // we pass the id to the updater which checks within the backend the correct place
848
                    var id = that.getId(ui.item);
10✔
849
                    var plugin = $(`.cms-draggable-${id}`);
10✔
850
                    var eventData = {
10✔
851
                        id: id
852
                    };
853
                    var previousParentPlugin = originalPluginContainer.closest('.cms-draggable');
10✔
854

855
                    if (previousParentPlugin.length) {
10✔
856
                        var previousParentPluginId = that.getId(previousParentPlugin);
3✔
857

858
                        eventData.previousParentPluginId = previousParentPluginId;
3✔
859
                    }
860

861
                    // check if we copy/paste a plugin or not
862
                    if (originalPluginContainer.hasClass('cms-clipboard-containers')) {
10✔
863
                        originalPluginContainer.html(plugin.eq(0).clone(true, true));
1✔
864
                        Plugin._updateClipboard();
1✔
865
                        plugin.trigger('cms-paste-plugin-update', [eventData]);
1✔
866
                    } else {
867
                        plugin.trigger('cms-plugins-update', [eventData]);
9✔
868
                    }
869

870
                    // reset placeholder without entries
871
                    that.ui.sortables.each(function() {
10✔
872
                        var element = $(this);
40✔
873

874
                        if (element.children().length === 0) {
40✔
875
                            element.addClass('cms-hidden');
7✔
876
                        }
877
                    });
878

879
                    StructureBoard.actualizePlaceholders();
10✔
880
                },
881
                // eslint-disable-next-line complexity
882
                isAllowed: function(placeholder, placeholderParent, originalItem) {
883
                    // cancel if action is executed
884
                    if (CMS.API.locked) {
14✔
885
                        return false;
1✔
886
                    }
887
                    // getting restriction array
888
                    var bounds = [];
13✔
889
                    var immediateParentType;
890

891
                    if (placeholder && placeholder.closest('.cms-clipboard-containers').length) {
13✔
892
                        return false;
1✔
893
                    }
894

895
                    // if parent has class disabled, dissalow drop
896
                    if (placeholder && placeholder.parent().hasClass('cms-draggable-disabled')) {
12✔
897
                        return false;
1✔
898
                    }
899

900
                    var originalItemId = that.getId(originalItem);
11✔
901
                    // save original state events
902
                    var original = $('.cms-draggable-' + originalItemId);
11✔
903

904
                    // cancel if item has no settings
905
                    if (original.length === 0 || !original.data('cms')) {
11✔
906
                        return false;
2✔
907
                    }
908
                    var originalItemData = original.data('cms');
9✔
909
                    var parent_bounds = $.grep(originalItemData.plugin_parent_restriction, function(r) {
9✔
910
                        // special case when PlaceholderPlugin has a parent restriction named "0"
911
                        return r !== '0';
3✔
912
                    });
913
                    var type = originalItemData.plugin_type;
9✔
914
                    // prepare variables for bound
915
                    var holderId = that.getId(placeholder.closest('.cms-dragarea'));
9✔
916
                    var holder = $('.cms-placeholder-' + holderId);
9✔
917
                    var plugin;
918

919
                    if (placeholderParent && placeholderParent.length) {
9✔
920
                        // placeholderParent is always latest, it maybe that
921
                        // isAllowed is called _before_ placeholder is moved to a child plugin
922
                        plugin = $('.cms-draggable-' + that.getId(placeholderParent.closest('.cms-draggable')));
1✔
923
                    } else {
924
                        plugin = $('.cms-draggable-' + that.getId(placeholder.closest('.cms-draggable')));
8✔
925
                    }
926

927
                    // now set the correct bounds
928
                    // istanbul ignore else
929
                    if (holder.length) {
9✔
930
                        bounds = holder.data('cms').plugin_restriction;
9✔
931
                        immediateParentType = holder.data('cms').plugin_type;
9✔
932
                    }
933
                    if (plugin.length) {
9✔
934
                        bounds = plugin.data('cms').plugin_restriction;
7✔
935
                        immediateParentType = plugin.data('cms').plugin_type;
7✔
936
                    }
937

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

941
                    // check if we have a parent restriction
942
                    if (parent_bounds.length) {
9✔
943
                        that.state = $.inArray(immediateParentType, parent_bounds) !== -1;
2✔
944
                    }
945

946
                    return that.state;
9✔
947
                }
948
            })
949
            .on('cms-structure-update', StructureBoard.actualizePlaceholders);
950
    }
951

952
    _dragRefresh() {
953
        this.ui.sortables.each((i, el) => {
11✔
954
            const element = $(el);
41✔
955

956
            if (element.data('mjsNestedSortable')) {
41!
957
                return;
×
958
            }
959

960
            this._drag(element);
41✔
961
        });
962
    }
963

964
    /**
965
     * @method invalidateState
966
     * @param {String} action - action to handle
967
     * @param {Object} data - data required to handle the object
968
     * @param {Object} opts
969
     * @param {Boolean} [opts.propagate=true] - should we propagate the change to other tabs or not
970
     */
971
    // eslint-disable-next-line complexity
972
    invalidateState(action, data, { propagate = true } = {}) {
50✔
973
        // eslint-disable-next-line default-case
974
        switch (action) {
25✔
975
            case 'COPY': {
976
                this.handleCopyPlugin(data);
2✔
977
                break;
2✔
978
            }
979

980
            case 'ADD': {
981
                this.handleAddPlugin(data);
2✔
982
                break;
2✔
983
            }
984

985
            case 'EDIT': {
986
                this.handleEditPlugin(data);
2✔
987
                break;
2✔
988
            }
989

990
            case 'DELETE': {
991
                this.handleDeletePlugin(data);
2✔
992
                break;
2✔
993
            }
994

995
            case 'CLEAR_PLACEHOLDER': {
996
                this.handleClearPlaceholder(data);
2✔
997
                break;
2✔
998
            }
999

1000
            case 'PASTE':
1001
            case 'MOVE': {
1002
                this.handleMovePlugin(data);
4✔
1003
                break;
4✔
1004
            }
1005

1006
            case 'CUT': {
1007
                this.handleCutPlugin(data);
2✔
1008
                break;
2✔
1009
            }
1010
        }
1011

1012
        if (!action) {
25✔
1013
            CMS.API.Helpers.reloadBrowser();
1✔
1014
            return;
1✔
1015
        }
1016

1017
        if (propagate) {
24!
1018
            this._propagateInvalidatedState(action, data);
24✔
1019
        }
1020

1021
        // refresh content mode if needed
1022
        // refresh toolbar
1023
        var currentMode = CMS.settings.mode;
24✔
1024

1025
        if (currentMode === 'structure') {
24✔
1026
            this._requestcontent = null;
22✔
1027

1028
            if (this._loadedContent && action !== 'COPY') {
22✔
1029
                this.updateContent();
2✔
1030
                return;  // Toolbar loaded
2✔
1031
            }
1032
        } else if (action !== 'COPY') {
2!
1033
            this._requestcontent = null;
2✔
1034
            this.updateContent();
2✔
1035
            return;  // Toolbar loaded
2✔
1036

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

1045
    _propagateInvalidatedState(action, data) {
1046
        this.latestAction = [action, data];
24✔
1047

1048
        ls.set(storageKey, JSON.stringify([action, data, window.location.pathname]));
24✔
1049
    }
1050

1051
    _listenToExternalUpdates() {
1052
        if (!Helpers._isStorageSupported) {
126✔
1053
            return;
3✔
1054
        }
1055

1056
        ls.on(storageKey, this._handleExternalUpdate.bind(this));
123✔
1057
    }
1058

1059
    _handleExternalUpdate(value) {
1060
        // means localstorage was cleared while this page was open
1061
        if (!value) {
×
1062
            return;
×
1063
        }
1064

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

1067
        if (pathname !== window.location.pathname) {
×
1068
            return;
×
1069
        }
1070

1071
        if (isEqual([action, data], this.latestAction)) {
×
1072
            return;
×
1073
        }
1074

1075
        this.invalidateState(action, data, { propagate: false });
×
1076
    }
1077

1078
    updateContent() {
1079
        const loader = $('<div class="cms-content-reloading"></div>');
4✔
1080

1081
        $('.cms-structure').before(loader);
4✔
1082

1083
        return this._requestMode('content')
4✔
1084
            .done(markup => {
1085
                // eslint-disable-next-line no-magic-numbers
1086
                loader.fadeOut(100, () => loader.remove());
2✔
1087
                this.refreshContent(markup);
2✔
1088
            })
1089
            .fail(() => loader.remove() && Helpers.reloadBrowser());
2✔
1090
    }
1091

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

1095
        return $.ajax({
1✔
1096
            url: Helpers.updateUrlWithPath(
1097
                `${CMS.config.request.toolbar}?` +
1098
                    placeholderIds +
1099
                    '&' +
1100
                    `obj_id=${CMS.config.request.pk}&` +
1101
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}`
1102
            )
1103
        });
1104
    }
1105

1106
    // i think this should probably be a separate class at this point that handles all the reloading
1107
    // stuff, it's a bit too much
1108
    // eslint-disable-next-line complexity
1109
    handleMovePlugin(data) {
1110
        if (data.plugin_parent) {
5✔
1111
            if (data.plugin_id) {
1!
1112
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);
1✔
1113

1114
                if (
1!
1115
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1116
                    !draggable.is('.cms-draggable-from-clipboard')
1117
                ) {
1118
                    draggable.remove();
1✔
1119
                }
1120
            }
1121

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

1130
            // external update, have to move the draggable to correct place first
1131
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1132
                const pluginOrder = data.plugin_order;
2✔
1133
                const index = findIndex(
2✔
1134
                    pluginOrder,
1135
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
×
1136
                );
1137
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1138

1139
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
1140
                    draggable = draggable.clone();
×
1141
                }
1142

1143
                if (index === 0) {
2!
1144
                    placeholderDraggables.prepend(draggable);
×
1145
                } else if (index !== -1) {
2!
1146
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
×
1147
                }
1148
            }
1149

1150
            // if we _are_ in the correct placeholder we still need to check if the order is correct
1151
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
1152
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1153
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1154
                const actualPluginOrder = this.getIds(
2✔
1155
                    placeholderDraggables.find('> .cms-draggable')
1156
                );
1157

1158
                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
2!
1159
                    // so the plugin order is not correct, means it's an external update and we need to move
1160
                    const pluginOrder = data.plugin_order;
2✔
1161
                    const index = findIndex(
2✔
1162
                        pluginOrder,
1163
                        pluginId => Number(pluginId) === Number(data.plugin_id)
3✔
1164
                    );
1165

1166
                    if (index === 0) {
2✔
1167
                        placeholderDraggables.prepend(draggable);
1✔
1168
                    } else if (index !== -1) {
1!
1169
                        placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
1✔
1170
                    }
1171
                }
1172
            }
1173

1174
            if (draggable.length) {
4✔
1175
                // empty the children first because replaceWith takes too much time
1176
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1177
                draggable.html('').replaceWith(data.html);
3✔
1178
            } else if (data.target_placeholder_id) {
1!
1179
                // copy from language
1180
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
1✔
1181
            }
1182
        }
1183

1184
        StructureBoard.actualizePlaceholders();
5✔
1185
        Plugin._updateRegistry(data.plugins);
5✔
1186
        data.plugins.forEach(pluginData => {
5✔
1187
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
7✔
1188
        });
1189

1190
        StructureBoard._initializeDragItemsStates();
5✔
1191

1192
        this.ui.sortables = $('.cms-draggables');
5✔
1193
        this._dragRefresh();
5✔
1194
    }
1195

1196
    handleCopyPlugin(data) {
1197
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1198
            CMS.API.Clipboard.modal.close();
1✔
1199
        }
1200

1201
        $('.cms-clipboard-containers').html(data.html);
2✔
1202
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1203

1204
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1205

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

1208
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1209
        CMS._plugins.push(pluginData);
2✔
1210
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1211

1212
        CMS.API.Clipboard = new Clipboard();
2✔
1213

1214
        Plugin._updateClipboard();
2✔
1215

1216
        let html = '';
2✔
1217

1218
        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');
2✔
1219

1220
        html = clipboardDraggable.parent().html();
2✔
1221

1222
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1223
        CMS.API.Clipboard._enableTriggers();
2✔
1224

1225
        this.ui.sortables = $('.cms-draggables');
2✔
1226
        this._dragRefresh();
2✔
1227
    }
1228

1229
    handleCutPlugin(data) {
1230
        this.handleDeletePlugin(data);
1✔
1231
        this.handleCopyPlugin(data);
1✔
1232
    }
1233

1234
    _extractMessages(doc) {
1235
        let messageList = doc.find('.messagelist');
6✔
1236
        let messages = messageList.find('li');
6✔
1237

1238
        if (!messageList.length || !messages.length) {
6✔
1239
            messageList = doc.find('[data-cms-messages-container]');
5✔
1240
            messages = messageList.find('[data-cms-message]');
5✔
1241
        }
1242

1243
        if (messages.length) {
6✔
1244
            messageList.remove();
3✔
1245

1246
            return compact(
3✔
1247
                messages.toArray().map(el => {
1248
                    const msgEl = $(el);
7✔
1249
                    const message = $(el).text().trim();
7✔
1250

1251
                    if (message) {
7✔
1252
                        return {
6✔
1253
                            message,
1254
                            error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
10✔
1255
                        };
1256
                    }
1257
                })
1258
            );
1259
        }
1260

1261
        return [];
3✔
1262
    }
1263

1264
    refreshContent(contentMarkup) {
1265
        this._requestcontent = null;
3✔
1266
        if (!this._loadedStructure) {
3!
1267
            this._requeststructure = null;
3✔
1268
        }
1269
        var fixedContentMarkup = contentMarkup;
3✔
1270
        var newDoc = new DOMParser().parseFromString(fixedContentMarkup, 'text/html');
3✔
1271
        let newScripts = $(newDoc).find('script');
3✔
1272
        let oldScripts = $(document).find('script');
3✔
1273

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

1276
        var toolbar = $('#cms-top, [data-cms]').detach();
3✔
1277
        var newToolbar = $(newDoc).find('.cms-toolbar').clone();
3✔
1278

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

1281
        const messages = this._extractMessages($(newDoc));
3✔
1282

1283
        if (messages.length) {
3✔
1284
            setTimeout(() =>
1✔
1285
                messages.forEach(message => {
1✔
1286
                    CMS.API.Messages.open(message);
1✔
1287
                })
1288
            );
1289
        }
1290

1291
        var headDiff = dd.diff(document.head, newDoc.head);
3✔
1292

1293
        StructureBoard._replaceBodyWithHTML(newDoc.body.innerHTML);
3✔
1294
        dd.apply(document.head, headDiff);
3✔
1295
        toolbar.prependTo(document.body);
3✔
1296
        CMS.API.Toolbar._refreshMarkup(newToolbar);
3✔
1297

1298
        this.addJsScriptsNeededForRender(newScripts, oldScripts);
3✔
1299

1300
        $('.cms-structure-content').scrollTop(structureScrollTop);
3✔
1301
        Plugin._refreshPlugins();
3✔
1302
        $(Helpers._getWindow()).trigger('cms-content-refresh');
3✔
1303

1304
        this._loadedContent = true;
3✔
1305
    }
1306

1307
    /**
1308
     * Checks if new scripts with the class 'cms-execute-js-to-render' exist
1309
     * and if they were present before. If they weren't present before - they will be downloaded
1310
     * and executed. If the script also has the class 'cms-trigger-load-events' the
1311
     * 'load' and 'DOMContentLoaded' events will be triggered
1312
     *
1313
     * @param {jQuery} newScripts  jQuery selector of the scripts for the new body content
1314
     * @param {jQuery} oldScripts  jQuery selector of the scripts for the old body content
1315
     */
1316
    addJsScriptsNeededForRender(newScripts, oldScripts) {
1317
        const scriptSrcList = [];
3✔
1318
        let classListCollection = [];
3✔
1319
        const that = this;
3✔
1320

1321
        newScripts.each(function() {
3✔
1322
            let scriptExists = false;
×
1323
            let newScript = $(this);
×
1324

1325
            if (newScript.hasClass('cms-execute-js-to-render')) {
×
1326
                $(oldScripts).each(function() {
×
1327
                    let oldScript = $(this);
×
1328

1329
                    if (newScript.prop('outerHTML') === oldScript.prop('outerHTML')) {
×
1330
                        scriptExists = true;
×
1331
                        return false;
×
1332
                    }
1333
                });
1334
                if (!scriptExists) {
×
1335
                    let classList = newScript.attr('class').split(' ');
×
1336

1337
                    classListCollection = classListCollection.concat(classList);
×
1338
                    if (typeof newScript.prop('src') === 'string' && newScript.prop('src') !== '') {
×
1339
                        scriptSrcList.push(newScript.prop('src'));
×
1340
                    } else {
1341
                        let jsFile = document.createElement('script');
×
1342

1343
                        jsFile.textContent = newScript.prop('textContent') || '';
×
1344
                        jsFile.type = 'text/javascript';
×
1345
                        document.body.appendChild(jsFile);
×
1346
                    }
1347
                }
1348
            }
1349
        });
1350
        if (scriptSrcList.length === 0) {
3!
1351
            that.triggerLoadEventsByClass(classListCollection);
3✔
1352
        } else {
1353
            Promise.all(scriptSrcList.map(s => $.getScript(s))).then(function() {
×
1354
                that.triggerLoadEventsByClass(classListCollection);
×
1355
            });
1356
        }
1357
    }
1358

1359
    /**
1360
     * Triggers events if specific classes were in any of the scripts added by
1361
     * the method addJsScriptsNeededForRender
1362
     *
1363
     * @param {String[]} classListCollection  array of all classes the script tags had
1364
     */
1365
    triggerLoadEventsByClass(classListCollection) {
1366
        if (classListCollection.indexOf('cms-trigger-event-document-DOMContentLoaded') > -1) {
3!
1367
            Helpers._getWindow().document.dispatchEvent(new Event('DOMContentLoaded'));
×
1368
        }
1369
        if (classListCollection.indexOf('cms-trigger-event-window-DOMContentLoaded') > -1) {
3!
1370
            Helpers._getWindow().dispatchEvent(new Event('DOMContentLoaded'));
×
1371
        }
1372
        if (classListCollection.indexOf('cms-trigger-event-window-load') > -1) {
3!
1373
            Helpers._getWindow().dispatchEvent(new Event('load'));
×
1374
        }
1375
    }
1376

1377
    handleAddPlugin(data) {
1378
        if (data.plugin_parent) {
2✔
1379
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1380
        } else {
1381
            // the one in the clipboard is first
1382
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
1✔
1383
        }
1384

1385
        StructureBoard.actualizePlaceholders();
2✔
1386
        Plugin._updateRegistry(data.structure.plugins);
2✔
1387
        data.structure.plugins.forEach(pluginData => {
2✔
1388
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
3✔
1389
        });
1390

1391
        this.ui.sortables = $('.cms-draggables');
2✔
1392
        this._dragRefresh();
2✔
1393
    }
1394

1395
    handleEditPlugin(data) {
1396
        if (data.plugin_parent) {
2✔
1397
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1398
        } else {
1399
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
1✔
1400
        }
1401

1402
        Plugin._updateRegistry(data.structure.plugins);
2✔
1403

1404
        data.structure.plugins.forEach(pluginData => {
2✔
1405
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
2✔
1406
        });
1407

1408
        this.ui.sortables = $('.cms-draggables');
2✔
1409
        this._dragRefresh();
2✔
1410
    }
1411

1412
    handleDeletePlugin(data) {
1413
        var deletedPluginIds = [data.plugin_id];
2✔
1414
        var draggable = $('.cms-draggable-' + data.plugin_id);
2✔
1415
        var children = draggable.find('.cms-draggable');
2✔
1416
        let parent = draggable.parent().closest('.cms-draggable');
2✔
1417

1418
        if (!parent.length) {
2✔
1419
            parent = draggable.closest('.cms-dragarea');
1✔
1420
        }
1421

1422
        if (children.length) {
2✔
1423
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
1✔
1424
        }
1425

1426
        draggable.remove();
2✔
1427

1428
        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
2✔
1429
        StructureBoard.actualizePlaceholders();
2✔
1430
        deletedPluginIds.forEach(function(pluginId) {
2✔
1431
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
5✔
1432
            remove(
3✔
1433
                CMS._instances,
1434
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
5✔
1435
            );
1436
        });
1437
    }
1438

1439
    handleClearPlaceholder(data) {
1440
        var deletedIds = CMS._instances
1✔
1441
            .filter(instance => {
1442
                if (
3✔
1443
                    instance.options.plugin_id &&
6✔
1444
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
1445
                ) {
1446
                    return true;
2✔
1447
                }
1448
            })
1449
            .map(instance => instance.options.plugin_id);
2✔
1450

1451
        deletedIds.forEach(id => {
1✔
1452
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
5✔
1453
            remove(
2✔
1454
                CMS._instances,
1455
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
5✔
1456
            );
1457

1458
            $(`.cms-draggable-${id}`).remove();
2✔
1459
        });
1460

1461
        StructureBoard.actualizePlaceholders();
1✔
1462
    }
1463

1464
    /**
1465
     * Similar to CMS.Plugin populates globally required
1466
     * variables, that only need querying once, e.g. placeholders.
1467
     *
1468
     * @method _initializeGlobalHandlers
1469
     * @static
1470
     * @private
1471
     */
1472
    static _initializeGlobalHandlers() {
1473
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
77✔
1474
    }
1475

1476
    /**
1477
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
1478
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
1479
     *
1480
     * @function actualizePlaceholders
1481
     * @private
1482
     */
1483
    static actualizePlaceholders() {
1484
        placeholders.each(function() {
156✔
1485
            var placeholder = $(this);
465✔
1486
            var copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');
465✔
1487

1488
            if (
465✔
1489
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
1490
                    .length
1491
            ) {
1492
                placeholder.removeClass('cms-dragarea-empty');
155✔
1493
                copyAll.removeClass('cms-submenu-item-disabled');
155✔
1494
                copyAll.find('> a').removeAttr('aria-disabled');
155✔
1495
            } else {
1496
                placeholder.addClass('cms-dragarea-empty');
310✔
1497
                copyAll.addClass('cms-submenu-item-disabled');
310✔
1498
                copyAll.find('> a').attr('aria-disabled', 'true');
310✔
1499
            }
1500
        });
1501

1502
        const addPluginPlaceholder = $('.cms-dragarea .cms-add-plugin-placeholder');
156✔
1503

1504
        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
156!
1505
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
×
1506
        }
1507
    }
1508

1509
    /**
1510
     * actualizePluginCollapseStatus
1511
     *
1512
     * @public
1513
     * @param {String} pluginId open the plugin if it should be open
1514
     */
1515
    static actualizePluginCollapseStatus(pluginId) {
1516
        const el = $(`.cms-draggable-${pluginId}`);
1✔
1517
        const open = find(CMS.settings.states, openPluginId => Number(openPluginId) === Number(pluginId));
1✔
1518

1519
        // only add this class to elements which have a draggable area
1520
        // istanbul ignore else
1521
        if (open && el.find('> .cms-draggables').length) {
1✔
1522
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1✔
1523
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1✔
1524
        }
1525
    }
1526

1527
    /**
1528
     * @function actualizePluginsCollapsibleStatus
1529
     * @private
1530
     * @param {jQuery} els lists of plugins (.cms-draggables)
1531
     */
1532
    static actualizePluginsCollapsibleStatus(els) {
1533
        els.each(function() {
9✔
1534
            var childList = $(this);
14✔
1535
            var pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');
14✔
1536

1537
            if (childList.children().length) {
14✔
1538
                pluginDragItem.addClass('cms-dragitem-collapsable');
10✔
1539
                if (childList.children().is(':visible')) {
10!
1540
                    pluginDragItem.addClass('cms-dragitem-expanded');
10✔
1541
                }
1542
            } else {
1543
                pluginDragItem.removeClass('cms-dragitem-collapsable');
4✔
1544
            }
1545
        });
1546
    }
1547

1548
    static _replaceBodyWithHTML(html) {
1549
        document.body.innerHTML = html;
×
1550
    }
1551

1552
    highlightPluginFromUrl() {
1553
        const hash = window.location.hash;
116✔
1554
        const regex = /cms-plugin-(\d+)/;
116✔
1555

1556
        if (!hash || !hash.match(regex)) {
116!
1557
            return;
116✔
1558
        }
1559

1560
        const pluginId = regex.exec(hash)[1];
×
1561

1562
        if (this._loadedContent) {
×
1563
            Plugin._highlightPluginContent(pluginId, {
×
1564
                seeThrough: true,
1565
                prominent: true,
1566
                delay: 3000
1567
            });
1568
        }
1569
    }
1570

1571
    /**
1572
     * Get's plugins data from markup
1573
     *
1574
     * @method _getPluginDataFromMarkup
1575
     * @private
1576
     * @param {String} markup
1577
     * @param {Array<Number | String>} pluginIds
1578
     * @returns {Array<[String, Object]>}
1579
     */
1580
    static _getPluginDataFromMarkup(markup, pluginIds) {
1581
        return compact(
9✔
1582
            pluginIds.map(pluginId => {
1583
                // oh boy
1584
                const regex = new RegExp(`CMS._plugins.push\\((\\["cms\-plugin\-${pluginId}",[\\s\\S]*?\\])\\)`, 'g');
15✔
1585
                const matches = regex.exec(markup);
15✔
1586
                let settings;
1587

1588
                if (matches) {
15✔
1589
                    try {
5✔
1590
                        settings = JSON.parse(matches[1]);
5✔
1591
                    } catch (e) {
1592
                        settings = false;
2✔
1593
                    }
1594
                } else {
1595
                    settings = false;
10✔
1596
                }
1597

1598
                return settings;
15✔
1599
            })
1600
        );
1601
    }
1602

1603
}
1604

1605
/**
1606
 * Initializes the collapsed/expanded states of dragitems in structureboard.
1607
 *
1608
 * @method _initializeDragItemsStates
1609
 * @static
1610
 * @private
1611
 */
1612
// istanbul ignore next
1613
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
1614
    // removing duplicate entries
1615
    var states = CMS.settings.states || [];
1616
    var sortedArr = states.sort();
1617
    var filteredArray = [];
1618

1619
    for (var i = 0; i < sortedArr.length; i++) {
1620
        if (sortedArr[i] !== sortedArr[i + 1]) {
1621
            filteredArray.push(sortedArr[i]);
1622
        }
1623
    }
1624
    CMS.settings.states = filteredArray;
1625

1626
    // loop through the items
1627
    $.each(CMS.settings.states, function(index, id) {
1628
        var el = $('.cms-draggable-' + id);
1629

1630
        // only add this class to elements which have immediate children
1631
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
1632
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1633
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1634
        }
1635
    });
1636
};
1637

1638
// shorthand for jQuery(document).ready();
1639
$(StructureBoard._initializeGlobalHandlers);
1✔
1640

1641
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