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

divio / django-cms / #29408

04 Feb 2025 06:07PM UTC coverage: 75.134% (-1.0%) from 76.152%
#29408

push

travis-ci

web-flow
Merge 7cc4a0075 into 0cf3792ca

1054 of 1599 branches covered (65.92%)

28 of 60 new or added lines in 1 file covered. (46.67%)

13 existing lines in 1 file now uncovered.

2526 of 3362 relevant lines covered (75.13%)

26.44 hits per line

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

87.25
/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 { DiffDOM } from 'diff-dom';
11
import PreventParentScroll from 'prevent-parent-scroll';
12
import { find, findIndex, once, remove, compact, isEqual, zip, every } from 'lodash';
13
import ls from 'local-storage';
14

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

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

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

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

29
let placeholders;
30
let originalPluginContainer;
31

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

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

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

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

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

63
        dd = new DiffDOM();
126✔
64

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

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

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

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

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

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

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

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

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

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

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

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

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

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

176
    _events() {
177
        this.ui.window.on('resize.cms.structureboard', () => {
126✔
178
            if (!this._loadedContent || CMS.config.mode !== 'draft') {
8,853!
179
                return;
8,853✔
180
            }
181
            const width = this.ui.window[0].innerWidth;
×
182
            const BREAKPOINT = 1024;
×
183

184
            if (width > BREAKPOINT && !this.condensed) {
×
185
                this._makeCondensed();
×
186
            }
187

188
            if (width <= BREAKPOINT && this.condensed) {
×
189
                this._makeFullWidth();
×
190
            }
191
        });
192
    }
193

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

373
        return this._loadStructure().then(this._showBoard.bind(this, init));
104✔
374
    }
375

376
    _loadStructure() {
377
        // case when structure mode is already loaded
378
        if (CMS.config.settings.mode === 'structure' || this._loadedStructure) {
100✔
379
            return Promise.resolve();
99✔
380
        }
381

382
        showLoader();
1✔
383
        return this
1✔
384
            ._requestMode('structure')
385
            .done(contentMarkup => {
386
                this._requeststructure = null;
1✔
387
                hideLoader();
1✔
388

389
                CMS.settings.states = Helpers.getSettings().states;
1✔
390

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

394
                var structure = body.find('.cms-structure-content');
1✔
395
                var toolbar = body.find('.cms-toolbar');
1✔
396
                var scripts = body.filter(function() {
1✔
397
                    var elem = $(this);
1✔
398

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

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

411
                CMS.API.Toolbar._refreshMarkup(toolbar);
1✔
412

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

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

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

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

442
    _requestMode(mode) {
443
        let url;
444

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

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

458
                return markup;
3✔
459
            });
460
        }
461

462
        return this[`_request${mode}`];
4✔
463
    }
464

465
    _loadContent() {
466
        var that = this;
47✔
467

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

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

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

501
                body = body.filter(function() {
1✔
502
                    var elem = $(this);
1✔
503

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

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

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

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

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

525
                Plugin._refreshPlugins();
1✔
526

527
                const scripts = $('script');
1✔
528

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

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

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

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

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

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

567
        // set active item
568
        var modes = this.ui.toolbarModeLinks;
48✔
569

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

573
        CMS.settings.mode = 'edit';
48✔
574

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

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

593
        var id = null;
66✔
594
        var cls = el.attr('class').split(' ')[1];
66✔
595

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

608
        return id;
65✔
609
    }
610

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

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

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

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

642
        this.ui.container.show();
104✔
643
        hideLoader();
104✔
644

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

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

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

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

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

665
        var width = this.ui.toolbar.width();
43✔
666
        var scrollBarWidth = this.ui.window[0].innerWidth - width;
43✔
667

668
        if (!scrollBarWidth) {
43✔
669
            scrollBarWidth = measureScrollbar();
6✔
670
        }
671

672
        this.ui.html.removeClass('cms-overflow');
43✔
673

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

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

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

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

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

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

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

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

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

759
                    originalPluginContainer = ui.item.closest('.cms-draggables');
20✔
760

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

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

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

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

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

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

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

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

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

828
                    if (previousParentPlugin.length) {
10✔
829
                        var previousParentPluginId = that.getId(previousParentPlugin);
3✔
830

831
                        eventData.previousParentPluginId = previousParentPluginId;
3✔
832
                    }
833

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

933
            this._drag(element);
41✔
934
        });
935
    }
936

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

949
        switch (action) {
25✔
950
            case 'COPY': {
951
                this.handleCopyPlugin(data);
2✔
952
                updateNeeded = false;
2✔
953
                break;
2✔
954
            }
955

956
            case 'ADD': {
957
                updateNeeded = this.handleAddPlugin(data);
2✔
958
                break;
2✔
959
            }
960

961
            case 'EDIT': {
962
                updateNeeded = this.handleEditPlugin(data);
2✔
963
                break;
2✔
964
            }
965

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

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

976
            case 'PASTE':
977
            case 'MOVE': {
978
                this.handleMovePlugin(data);
4✔
979
                break;
4✔
980
            }
981

982
            case 'CUT': {
983
                this.handleCutPlugin(data);
2✔
984
                break;
2✔
985
            }
986

987
            default:
988
                CMS.API.Helpers.reloadBrowser();
9✔
989
                return;
9✔
990
        }
991

992
        Plugin._recalculatePluginPositions(action, data);
16✔
993

994
        if (propagate) {
16!
995
            this._propagateInvalidatedState(action, data);
16✔
996
        }
997

998
        // refresh content mode if needed
999
        // refresh toolbar
1000
        var currentMode = CMS.settings.mode;
16✔
1001

1002
        if (currentMode === 'structure') {
16!
1003
            this._requestcontent = null;
16✔
1004

1005
            if (this._loadedContent && updateNeeded) {
16!
UNCOV
1006
                this.updateContent();
×
UNCOV
1007
                return;  // Toolbar loaded
×
1008
            }
NEW
1009
        } else if (updateNeeded === true) {
×
UNCOV
1010
            this._requestcontent = null;
×
UNCOV
1011
            this.updateContent();
×
UNCOV
1012
            return;  // Toolbar loaded
×
1013
        }
1014
        this._loadToolbar()
16✔
1015
            .done(newToolbar => {
UNCOV
1016
                CMS.API.Toolbar._refreshMarkup($(newToolbar).find('.cms-toolbar'));
×
1017
            })
UNCOV
1018
            .fail(() => Helpers.reloadBrowser());
×
1019
    }
1020

1021
    _propagateInvalidatedState(action, data) {
1022
        this.latestAction = [action, data];
16✔
1023

1024
        ls.set(storageKey, JSON.stringify([action, data, window.location.pathname]));
16✔
1025
    }
1026

1027
    _listenToExternalUpdates() {
1028
        if (!Helpers._isStorageSupported) {
126✔
1029
            return;
3✔
1030
        }
1031

1032
        ls.on(storageKey, this._handleExternalUpdate.bind(this));
123✔
1033
    }
1034

1035
    _handleExternalUpdate(value) {
1036
        // means localstorage was cleared while this page was open
1037
        if (!value) {
×
1038
            return;
×
1039
        }
1040

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

1043
        if (pathname !== window.location.pathname) {
×
1044
            return;
×
1045
        }
1046

1047
        if (isEqual([action, data], this.latestAction)) {
×
1048
            return;
×
1049
        }
1050

1051
        this.invalidateState(action, data, { propagate: false });
×
1052
    }
1053

1054
    updateContent() {
UNCOV
1055
        const loader = $('<div class="cms-content-reloading"></div>');
×
1056

UNCOV
1057
        $('.cms-structure').before(loader);
×
1058

UNCOV
1059
        return this._requestMode('content')
×
1060
            .done(markup => {
1061
                // eslint-disable-next-line no-magic-numbers
UNCOV
1062
                loader.fadeOut(100, () => loader.remove());
×
UNCOV
1063
                this.refreshContent(markup);
×
1064
            })
UNCOV
1065
            .fail(() => loader.remove() && Helpers.reloadBrowser());
×
1066
    }
1067

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

1071
        return $.ajax({
1✔
1072
            url: Helpers.updateUrlWithPath(
1073
                `${CMS.config.request.toolbar}?` +
1074
                    placeholderIds +
1075
                    '&' +
1076
                    `obj_id=${CMS.config.request.pk}&` +
1077
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}`
1078
            )
1079
        });
1080
    }
1081

1082
    /**
1083
     * Updates the content of the plugin list.
1084
     *
1085
     * @method _updatePluginList
1086
     * @private
1087
     * @param {Object} data - The data containing the new plugin information, both markup and list.
1088
     * @param {jQuery} el - The element to update - otherwise first in the plugin list.
1089
     * @returns {Boolean} - Returns true if after calling the method an update is still needed.
1090
     */
1091
    _updatePluginList(data, el = null) {
4✔
1092
        let position = el;
4✔
1093

1094
        if (!data || !data.content || !data.content.pluginIds || !data.content.html) {
4!
1095
            return true;  // Update needed
4✔
1096
        }
NEW
1097
        if (el === null) {
×
1098
            // Get the position for replacing the plugin with id plugin_id
NEW
1099
            position = $(`:not(template).cms-plugin.cms-plugin-${data.content.pluginIds[0]}`).last();
×
1100
        }
NEW
1101
        if (position.length !== 1) {
×
NEW
1102
            return true; // Update needed
×
1103
        }
NEW
1104
        position.after(data.content.html);  // insert HTML
×
NEW
1105
        if (el === null) {
×
1106
            // No element given, i.e. remove the old plugin
1107
            // Go through all plugins and child plugins (they might not be nested)
NEW
1108
            data.content.pluginIds.forEach(id => {
×
NEW
1109
                $(`:not(template).cms-plugin.cms-plugin-${id}`).remove();
×
1110
            });
1111
        }
1112

NEW
1113
        if (data.content.css.length) {
×
NEW
1114
            const css = $(data.content.css);
×
1115

NEW
1116
            $('head').append(css);
×
1117
        }
NEW
1118
        if (data.content.js.length) {
×
NEW
1119
            const js = $(data.content.js);
×
1120

NEW
1121
            js.find('[data-cms], [data-cms-plugin]').remove();
×
NEW
1122
            $('body').append(js);
×
1123
        }
NEW
1124
        this._contentChanged(data.messages);
×
NEW
1125
        return false;
×
1126
    }
1127

1128
    _updateCMSScripts(data, add) {
1129
        if (data && data.content && data.content.js) {
2!
NEW
1130
            const js = $(data.content.js);
×
1131

NEW
1132
            js.find('[data-cms], [data-cms-plugin], [data-cms-placeholder], [data-cms-general]').each(el => {
×
NEW
1133
                if (add || !el.hasAttribute('data-cms')) {
×
NEW
1134
                    const existing = $(`#${el.id}`);
×
1135

NEW
1136
                    if (existing.length) {
×
NEW
1137
                        existing.replaceWith(el);
×
1138
                    } else {
NEW
1139
                        $('script[data-cms-config]').append(el);
×
1140
                    }
1141
                }
1142
            });
1143
        }
1144
    }
1145

1146
    _contentChanged(messages) {
1147
        Plugin._refreshPlugins();
3✔
1148

1149
        Helpers._getWindow().dispatchEvent(new Event('load'));
3✔
1150
        $(Helpers._getWindow()).trigger('cms-content-refresh');
3✔
1151
        if (messages) {
3!
NEW
1152
            CMS.API.Messages.close();
×
NEW
1153
            if (messages.length) {
×
NEW
1154
                CMS.API.Messages.open({
×
NEW
1155
                    message: messages.map(message => `<p>${message.message}</p>`).join(''),
×
NEW
1156
                    error: messages.some(message => message.level === 'error')
×
1157
                });
1158
            }
1159
        }
1160
    }
1161

1162
    // i think this should probably be a separate class at this point that handles all the reloading
1163
    // stuff, it's a bit too much
1164
    // eslint-disable-next-line complexity
1165
    handleMovePlugin(data) {
1166
        if (data.plugin_parent) {
5✔
1167
            if (data.plugin_id) {
1!
1168
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);
1✔
1169

1170
                if (
1!
1171
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1172
                    !draggable.is('.cms-draggable-from-clipboard')
1173
                ) {
1174
                    draggable.remove();
1✔
1175
                }
1176
            }
1177

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

1186
            // external update, have to move the draggable to correct place first
1187
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1188
                const pluginOrder = data.plugin_order;
2✔
1189
                const index = findIndex(
2✔
1190
                    pluginOrder,
1191
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
×
1192
                );
1193
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1194

1195
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
1196
                    draggable = draggable.clone();
×
1197
                }
1198

1199
                if (index === 0) {
2!
1200
                    placeholderDraggables.prepend(draggable);
×
1201
                } else if (index !== -1) {
2!
1202
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
×
1203
                }
1204
            }
1205

1206
            // if we _are_ in the correct placeholder we still need to check if the order is correct
1207
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
1208
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1209
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1210
                const actualPluginOrder = this.getIds(
2✔
1211
                    placeholderDraggables.find('> .cms-draggable')
1212
                );
1213

1214
                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
2!
1215
                    // so the plugin order is not correct, means it's an external update and we need to move
1216
                    const pluginOrder = data.plugin_order;
2✔
1217
                    const index = findIndex(
2✔
1218
                        pluginOrder,
1219
                        pluginId => Number(pluginId) === Number(data.plugin_id)
3✔
1220
                    );
1221

1222
                    if (index === 0) {
2✔
1223
                        placeholderDraggables.prepend(draggable);
1✔
1224
                    } else if (index !== -1) {
1!
1225
                        placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
1✔
1226
                    }
1227
                }
1228
            }
1229

1230
            if (draggable.length) {
4✔
1231
                // empty the children first because replaceWith takes too much time
1232
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1233
                draggable.html('').replaceWith(data.html);
3✔
1234
            } else if (data.target_placeholder_id) {
1!
1235
                // copy from language
1236
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
1✔
1237
            }
1238
        }
1239

1240
        StructureBoard.actualizePlaceholders();
5✔
1241
        Plugin._updateRegistry(data.plugins);
5✔
1242
        data.plugins.forEach(pluginData => {
5✔
1243
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
7✔
1244
        });
1245

1246
        StructureBoard._initializeDragItemsStates();
5✔
1247

1248
        this.ui.sortables = $('.cms-draggables');
5✔
1249
        this._dragRefresh();
5✔
1250
    }
1251

1252
    handleCopyPlugin(data) {
1253
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1254
            CMS.API.Clipboard.modal.close();
1✔
1255
        }
1256

1257
        $('.cms-clipboard-containers').html(data.html);
2✔
1258
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1259

1260
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1261

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

1264
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1265
        CMS._plugins.push(pluginData);
2✔
1266
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1267

1268
        CMS.API.Clipboard = new Clipboard();
2✔
1269

1270
        Plugin._updateClipboard();
2✔
1271

1272
        let html = '';
2✔
1273

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

1276
        html = clipboardDraggable.parent().html();
2✔
1277

1278
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1279
        CMS.API.Clipboard._enableTriggers();
2✔
1280

1281
        this.ui.sortables = $('.cms-draggables');
2✔
1282
        this._dragRefresh();
2✔
1283
    }
1284

1285
    handleCutPlugin(data) {
1286
        this.handleDeletePlugin(data);
1✔
1287
        this.handleCopyPlugin(data);
1✔
1288
    }
1289

1290
    _extractMessages(doc) {
1291
        let messageList = doc.find('.messagelist');
6✔
1292
        let messages = messageList.find('li');
6✔
1293

1294
        if (!messageList.length || !messages.length) {
6✔
1295
            messageList = doc.find('[data-cms-messages-container]');
5✔
1296
            messages = messageList.find('[data-cms-message]');
5✔
1297
        }
1298

1299
        if (messages.length) {
6✔
1300
            messageList.remove();
3✔
1301

1302
            return compact(
3✔
1303
                messages.toArray().map(el => {
1304
                    const msgEl = $(el);
7✔
1305
                    const message = $(el).text().trim();
7✔
1306

1307
                    if (message) {
7✔
1308
                        return {
6✔
1309
                            message,
1310
                            error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
10✔
1311
                        };
1312
                    }
1313
                })
1314
            );
1315
        }
1316

1317
        return [];
3✔
1318
    }
1319

1320
    refreshContent(contentMarkup) {
1321
        this._requestcontent = null;
3✔
1322
        if (!this._loadedStructure) {
3!
1323
            this._requeststructure = null;
3✔
1324
        }
1325
        const newDoc = new DOMParser().parseFromString(contentMarkup, 'text/html');
3✔
1326
        const structureScrollTop = $('.cms-structure-content').scrollTop();
3✔
1327

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

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

1333
        const messages = this._extractMessages($(newDoc));
3✔
1334

1335
        if (messages.length) {
3✔
1336
            setTimeout(() =>
1✔
1337
                messages.forEach(message => {
1✔
1338
                    CMS.API.Messages.open(message);
1✔
1339
                })
1340
            );
1341
        }
1342

1343
        var headDiff = dd.diff(document.head, newDoc.head);
3✔
1344

1345
        StructureBoard._replaceBodyWithHTML(newDoc.body);
3✔
1346
        dd.apply(document.head, headDiff);
3✔
1347
        toolbar.prependTo(document.body);
3✔
1348
        CMS.API.Toolbar._refreshMarkup(newToolbar);
3✔
1349
        this._loadedContent = true;
3✔
1350

1351
        $('.cms-structure-content').scrollTop(structureScrollTop);
3✔
1352

1353
        this._contentChanged();
3✔
1354
    }
1355

1356
    handleAddPlugin(data) {
1357
        if (data.plugin_parent) {
2✔
1358
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1359
        } else {
1360
            // the one in the clipboard is first
1361
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
1✔
1362
        }
1363

1364
        StructureBoard.actualizePlaceholders();
2✔
1365
        Plugin._updateRegistry(data.structure.plugins);
2✔
1366
        data.structure.plugins.forEach(pluginData => {
2✔
1367
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
3✔
1368
        });
1369

1370
        this.ui.sortables = $('.cms-draggables');
2✔
1371
        this._dragRefresh();
2✔
1372
        this._updateCMSScripts(data, true);
2✔
1373
        return this._updatePluginList(data);
2✔
1374
    }
1375

1376
    handleEditPlugin(data) {
1377
        if (data.plugin_parent) {
2✔
1378
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1379
        } else {
1380
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
1✔
1381
        }
1382

1383
        Plugin._updateRegistry(data.structure.plugins);
2✔
1384

1385
        data.structure.plugins.forEach(pluginData => {
2✔
1386
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
2✔
1387
        });
1388

1389
        this.ui.sortables = $('.cms-draggables');
2✔
1390
        this._dragRefresh();
2✔
1391
        return this._updatePluginList(data);
2✔
1392
    }
1393

1394
    handleDeletePlugin(data) {
1395
        var deletedPluginIds = [data.plugin_id];
2✔
1396
        var draggable = $('.cms-draggable-' + data.plugin_id);
2✔
1397
        var children = draggable.find('.cms-draggable');
2✔
1398
        let parent = draggable.parent().closest('.cms-draggable');
2✔
1399

1400
        if (!parent.length) {
2✔
1401
            parent = draggable.closest('.cms-dragarea');
1✔
1402
        }
1403

1404
        if (children.length) {
2✔
1405
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
1✔
1406
        }
1407

1408
        draggable.remove();
2✔
1409

1410
        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
2✔
1411
        StructureBoard.actualizePlaceholders();
2✔
1412
        deletedPluginIds.forEach(function(pluginId) {
2✔
1413
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
5✔
1414
            remove(
3✔
1415
                CMS._instances,
1416
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
5✔
1417
            );
1418
        });
1419
        return true;
2✔
1420
    }
1421

1422
    handleClearPlaceholder(data) {
1423
        var deletedIds = CMS._instances
1✔
1424
            .filter(instance => {
1425
                if (
3✔
1426
                    instance.options.plugin_id &&
6✔
1427
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
1428
                ) {
1429
                    return true;
2✔
1430
                }
1431
            })
1432
            .map(instance => instance.options.plugin_id);
2✔
1433

1434
        deletedIds.forEach(id => {
1✔
1435
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
5✔
1436
            remove(
2✔
1437
                CMS._instances,
1438
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
5✔
1439
            );
1440

1441
            $(`.cms-draggable-${id}`).remove();
2✔
1442
        });
1443

1444
        StructureBoard.actualizePlaceholders();
1✔
1445
        return true;
1✔
1446
    }
1447

1448
    /**
1449
     * Similar to CMS.Plugin populates globally required
1450
     * variables, that only need querying once, e.g. placeholders.
1451
     *
1452
     * @method _initializeGlobalHandlers
1453
     * @static
1454
     * @private
1455
     */
1456
    static _initializeGlobalHandlers() {
1457
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
77✔
1458
    }
1459

1460
    /**
1461
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
1462
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
1463
     *
1464
     * @function actualizePlaceholders
1465
     * @private
1466
     */
1467
    static actualizePlaceholders() {
1468
        placeholders.each(function() {
156✔
1469
            var placeholder = $(this);
465✔
1470
            var copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');
465✔
1471

1472
            if (
465✔
1473
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
1474
                    .length
1475
            ) {
1476
                placeholder.removeClass('cms-dragarea-empty');
155✔
1477
                copyAll.removeClass('cms-submenu-item-disabled');
155✔
1478
                copyAll.find('> a').removeAttr('aria-disabled');
155✔
1479
            } else {
1480
                placeholder.addClass('cms-dragarea-empty');
310✔
1481
                copyAll.addClass('cms-submenu-item-disabled');
310✔
1482
                copyAll.find('> a').attr('aria-disabled', 'true');
310✔
1483
            }
1484
        });
1485

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

1488
        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
156!
1489
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
×
1490
        }
1491
    }
1492

1493
    /**
1494
     * actualizePluginCollapseStatus
1495
     *
1496
     * @public
1497
     * @param {String} pluginId open the plugin if it should be open
1498
     */
1499
    static actualizePluginCollapseStatus(pluginId) {
1500
        const el = $(`.cms-draggable-${pluginId}`);
1✔
1501
        const open = find(CMS.settings.states, openPluginId => Number(openPluginId) === Number(pluginId));
1✔
1502

1503
        // only add this class to elements which have a draggable area
1504
        // istanbul ignore else
1505
        if (open && el.find('> .cms-draggables').length) {
1✔
1506
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1✔
1507
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1✔
1508
        }
1509
    }
1510

1511
    /**
1512
     * @function actualizePluginsCollapsibleStatus
1513
     * @private
1514
     * @param {jQuery} els lists of plugins (.cms-draggables)
1515
     */
1516
    static actualizePluginsCollapsibleStatus(els) {
1517
        els.each(function() {
9✔
1518
            var childList = $(this);
14✔
1519
            var pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');
14✔
1520

1521
            if (childList.children().length) {
14✔
1522
                pluginDragItem.addClass('cms-dragitem-collapsable');
10✔
1523
                if (childList.children().is(':visible')) {
10!
1524
                    pluginDragItem.addClass('cms-dragitem-expanded');
10✔
1525
                }
1526
            } else {
1527
                pluginDragItem.removeClass('cms-dragitem-collapsable');
4✔
1528
            }
1529
        });
1530
    }
1531

1532
    static _replaceBodyWithHTML(newBody) {
NEW
1533
        const diff = dd.diff(document.body, newBody);
×
1534

NEW
1535
        dd.apply(document.body, diff);
×
1536
    }
1537

1538
    highlightPluginFromUrl() {
1539
        const hash = window.location.hash;
116✔
1540
        const regex = /cms-plugin-(\d+)/;
116✔
1541

1542
        if (!hash || !hash.match(regex)) {
116!
1543
            return;
116✔
1544
        }
1545

1546
        const pluginId = regex.exec(hash)[1];
×
1547

1548
        if (this._loadedContent) {
×
1549
            Plugin._highlightPluginContent(pluginId, {
×
1550
                seeThrough: true,
1551
                prominent: true,
1552
                delay: 3000
1553
            });
1554
        }
1555
    }
1556

1557
    /**
1558
     * Get's plugins data from markup
1559
     *
1560
     * @method _getPluginDataFromMarkup
1561
     * @private
1562
     * @param {String} markup
1563
     * @param {Array<Number | String>} pluginIds
1564
     * @returns {Array<[String, Object]>}
1565
     */
1566
    static _getPluginDataFromMarkup(markup, pluginIds) {
1567
        return compact(
9✔
1568
            pluginIds.map(pluginId => {
1569
                // oh boy
1570
                const regex = new RegExp(`CMS._plugins.push\\((\\["cms\-plugin\-${pluginId}",[\\s\\S]*?\\])\\)`, 'g');
15✔
1571
                const matches = regex.exec(markup);
15✔
1572
                let settings;
1573

1574
                if (matches) {
15✔
1575
                    try {
5✔
1576
                        settings = JSON.parse(matches[1]);
5✔
1577
                    } catch (e) {
1578
                        settings = false;
2✔
1579
                    }
1580
                } else {
1581
                    settings = false;
10✔
1582
                }
1583

1584
                return settings;
15✔
1585
            })
1586
        );
1587
    }
1588

1589
}
1590

1591
/**
1592
 * Initializes the collapsed/expanded states of dragitems in structureboard.
1593
 *
1594
 * @method _initializeDragItemsStates
1595
 * @static
1596
 * @private
1597
 */
1598
// istanbul ignore next
1599
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
1600
    // removing duplicate entries
1601
    var states = CMS.settings.states || [];
1602
    var sortedArr = states.sort();
1603
    var filteredArray = [];
1604

1605
    for (var i = 0; i < sortedArr.length; i++) {
1606
        if (sortedArr[i] !== sortedArr[i + 1]) {
1607
            filteredArray.push(sortedArr[i]);
1608
        }
1609
    }
1610
    CMS.settings.states = filteredArray;
1611

1612
    // loop through the items
1613
    $.each(CMS.settings.states, function(index, id) {
1614
        var el = $('.cms-draggable-' + id);
1615

1616
        // only add this class to elements which have immediate children
1617
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
1618
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1619
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1620
        }
1621
    });
1622
};
1623

1624
// shorthand for jQuery(document).ready();
1625
$(StructureBoard._initializeGlobalHandlers);
1✔
1626

1627
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