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

divio / django-cms / #29417

10 Feb 2025 09:29PM UTC coverage: 75.103% (-0.02%) from 75.118%
#29417

push

travis-ci

web-flow
Merge b670dd8a8 into 733c377ab

1061 of 1604 branches covered (66.15%)

40 of 92 new or added lines in 2 files covered. (43.48%)

204 existing lines in 2 files now uncovered.

2543 of 3386 relevant lines covered (75.1%)

26.4 hits per line

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

86.6
/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, nodeToObj } 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
const DOMParser = window.DOMParser; // needed only for testing
1✔
26
const storageKey = 'cms-structure';
1✔
27

28
let dd;
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

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

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

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

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

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

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

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

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

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

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

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

1006
        // refresh content mode if needed
1007
        // refresh toolbar
1008
        var currentMode = CMS.settings.mode;
24✔
1009

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

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

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

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

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

1036
    _listenToExternalUpdates() {
1037
        if (!Helpers._isStorageSupported) {
126✔
1038
            return;
3✔
1039
        }
1040

1041
        ls.on(storageKey, this._handleExternalUpdate.bind(this));
123✔
1042
    }
1043

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

UNCOV
1050
        const [action, data, pathname] = JSON.parse(value);
×
1051

UNCOV
1052
        if (pathname !== window.location.pathname) {
×
1053
            return;
×
1054
        }
1055

UNCOV
1056
        if (isEqual([action, data], this.latestAction)) {
×
1057
            return;
×
1058
        }
1059

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

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

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

1076
    _updateContentFromDataBridge(data) {
1077
        if (!data || !data.content || !data.content.pluginIds ||
4!
1078
            data.content.pluginIds.length < 1 || !data.content.html) {
1079
            // Non content data available in data bridge? Full content upudate needed.
1080
            return true;  // Update needed
4✔
1081
        }
1082

NEW
1083
        const existingPlugins = $(`:not(template).cms-plugin.cms-plugin-${data.content.pluginIds[0]}.cms-plugin-last`);
×
1084

NEW
1085
        if (existingPlugins.length < 1) {
×
1086
            // Plugin not found, but placeholder is known - plugin was added
NEW
1087
            const placeholder = $(`div.cms-placeholder.cms-placeholder-${data.placeholder_id}`);
×
1088

NEW
1089
            if (placeholder.length === 0) {
×
1090
                // Placeholder not found - update needed
NEW
1091
                return true;
×
1092
            }
NEW
1093
            placeholder.before(data.content.html);
×
1094
        } else {
1095
            // Add new content after existing content
NEW
1096
            existingPlugins.after(data.content.html);
×
1097
        }
1098
        // Delete previous content
1099
        // Go through all plugins and child plugins (they might not be nested)
NEW
1100
        data.content.pluginIds.forEach(id => {
×
NEW
1101
            $(`:not(template).cms-plugin.cms-plugin-${id}`).remove();
×
1102
        });
NEW
1103
        this._updateSekizai(data, 'css');
×
NEW
1104
        this._updateSekizai(data, 'js');
×
1105

NEW
1106
        this._contentChanged(data.messages);
×
NEW
1107
        return false;
×
1108
    }
1109

1110
    _updateSekizai(data, block) {
NEW
1111
        if ((data.content[block] || '').length === 0) {
×
NEW
1112
            return;
×
1113
        }
1114

1115
        // Find existing candiates, selector and cursor to write to
1116
        let current;
1117
        let selector;
1118
        let location;
1119

NEW
1120
        if (block === 'css') {
×
NEW
1121
            selector = 'link, style, meta';
×
NEW
1122
            current = document.head.querySelectorAll(selector);
×
NEW
1123
            location = document.head;
×
NEW
1124
        } else if (block === 'js') {
×
NEW
1125
            selector = 'script';
×
NEW
1126
            current = document.body.querySelectorAll(selector);
×
NEW
1127
            location = document.body;
×
1128
        } else {
NEW
1129
            return;
×
1130
        }
1131

1132
        // Parse new block, by creating the diff
1133
        // Cannot use innerHTML since this would prevent scripts to be executed.
NEW
1134
        const newElements = document.createElement('div');
×
NEW
1135
        const diff = dd.diff(newElements, `<div>${data.content[block]}</div>`);
×
1136

NEW
1137
        dd.apply(newElements, diff);
×
1138

1139
        // Collect deferred scripts to ensure firing
NEW
1140
        const deferred = [];
×
NEW
1141
        const loaded = [];
×
1142

NEW
1143
        for (const element of newElements.querySelectorAll(selector)) {
×
NEW
1144
            if (this._elementPresent(current, element)) {
×
NEW
1145
                element.remove();
×
1146
            } else {
NEW
1147
                if (element.hasAttribute('src')) {
×
NEW
1148
                    deferred.push(element);
×
NEW
1149
                    element.onerror = element.onload = el => {
×
NEW
1150
                        loaded.push(el);
×
NEW
1151
                        if (loaded.length === deferred.length) {
×
NEW
1152
                            Helpers._getWindow().dispatchEvent(new Event('load'));
×
NEW
1153
                            $(Helpers._getWindow()).trigger('cms-content-refresh');
×
1154
                        }
1155
                    };
1156
                }
NEW
1157
                location.appendChild(element);
×
1158
            }
1159
        }
NEW
1160
        return deferred.length > 0;
×
1161
    }
1162

1163
    _elementPresent(current, element) {
NEW
1164
        const markup = element.outerHTML;
×
1165

NEW
1166
        return [...current].some(el => el.outerHTML === markup);
×
1167
    }
1168

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

1172
        return $.ajax({
1✔
1173
            url: Helpers.updateUrlWithPath(
1174
                `${CMS.config.request.toolbar}?` +
1175
                    placeholderIds +
1176
                    '&' +
1177
                    `obj_id=${CMS.config.request.pk}&` +
1178
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}`
1179
            )
1180
        });
1181
    }
1182

1183
    // i think this should probably be a separate class at this point that handles all the reloading
1184
    // stuff, it's a bit too much
1185
    // eslint-disable-next-line complexity
1186
    handleMovePlugin(data) {
1187
        if (data.plugin_parent) {
5✔
1188
            if (data.plugin_id) {
1!
1189
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);
1✔
1190

1191
                if (
1!
1192
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
2✔
1193
                    !draggable.is('.cms-draggable-from-clipboard')
1194
                ) {
1195
                    draggable.remove();
1✔
1196
                }
1197
            }
1198

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

1207
            // external update, have to move the draggable to correct place first
1208
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1209
                const pluginOrder = data.plugin_order;
2✔
1210
                const index = findIndex(
2✔
1211
                    pluginOrder,
1212
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
×
1213
                );
1214
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1215

1216
                if (draggable.is('.cms-draggable-from-clipboard')) {
2!
UNCOV
1217
                    draggable = draggable.clone();
×
1218
                }
1219

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

1227
            // if we _are_ in the correct placeholder we still need to check if the order is correct
1228
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
1229
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
4✔
1230
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
2✔
1231
                const actualPluginOrder = this.getIds(
2✔
1232
                    placeholderDraggables.find('> .cms-draggable')
1233
                );
1234

1235
                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
2!
1236
                    // so the plugin order is not correct, means it's an external update and we need to move
1237
                    const pluginOrder = data.plugin_order;
2✔
1238
                    const index = findIndex(
2✔
1239
                        pluginOrder,
1240
                        pluginId => Number(pluginId) === Number(data.plugin_id)
3✔
1241
                    );
1242

1243
                    if (index === 0) {
2✔
1244
                        placeholderDraggables.prepend(draggable);
1✔
1245
                    } else if (index !== -1) {
1!
1246
                        placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
1✔
1247
                    }
1248
                }
1249
            }
1250

1251
            if (draggable.length) {
4✔
1252
                // empty the children first because replaceWith takes too much time
1253
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
1254
                draggable.html('').replaceWith(data.html);
3✔
1255
            } else if (data.target_placeholder_id) {
1!
1256
                // copy from language
1257
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
1✔
1258
            }
1259
        }
1260

1261
        StructureBoard.actualizePlaceholders();
5✔
1262
        Plugin._updateRegistry(data.plugins);
5✔
1263
        data.plugins.forEach(pluginData => {
5✔
1264
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
7✔
1265
        });
1266

1267
        StructureBoard._initializeDragItemsStates();
5✔
1268

1269
        this.ui.sortables = $('.cms-draggables');
5✔
1270
        this._dragRefresh();
5✔
1271
        return true;  // update needed
5✔
1272
    }
1273

1274
    handleCopyPlugin(data) {
1275
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
2✔
1276
            CMS.API.Clipboard.modal.close();
1✔
1277
        }
1278

1279
        $('.cms-clipboard-containers').html(data.html);
2✔
1280
        const cloneClipboard = $('.cms-clipboard').clone();
2✔
1281

1282
        $('.cms-clipboard').replaceWith(cloneClipboard);
2✔
1283

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

1286
        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
2✔
1287
        CMS._plugins.push(pluginData);
2✔
1288
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));
2✔
1289

1290
        CMS.API.Clipboard = new Clipboard();
2✔
1291

1292
        Plugin._updateClipboard();
2✔
1293

1294
        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');
2✔
1295
        const html = clipboardDraggable.parent().html();
2✔
1296

1297
        CMS.API.Clipboard.populate(html, pluginData[1]);
2✔
1298
        CMS.API.Clipboard._enableTriggers();
2✔
1299

1300
        this.ui.sortables = $('.cms-draggables');
2✔
1301
        this._dragRefresh();
2✔
1302
        return true;  // update needed
2✔
1303
    }
1304

1305
    handleCutPlugin(data) {
1306
        const updateNeededFromDelete = this.handleDeletePlugin(data);
1✔
1307
        const updateNeededFromCopy = this.handleCopyPlugin(data);
1✔
1308

1309
        return updateNeededFromDelete || updateNeededFromCopy;
1✔
1310
    }
1311

1312
    _extractMessages(doc) {
1313
        let messageList = doc.find('.messagelist');
6✔
1314
        let messages = messageList.find('li');
6✔
1315

1316
        if (!messageList.length || !messages.length) {
6✔
1317
            messageList = doc.find('[data-cms-messages-container]');
5✔
1318
            messages = messageList.find('[data-cms-message]');
5✔
1319
        }
1320

1321
        if (messages.length) {
6✔
1322
            messageList.remove();
3✔
1323

1324
            return compact(
3✔
1325
                messages.toArray().map(el => {
1326
                    const msgEl = $(el);
7✔
1327
                    const message = $(el).text().trim();
7✔
1328

1329
                    if (message) {
7✔
1330
                        return {
6✔
1331
                            message,
1332
                            error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
10✔
1333
                        };
1334
                    }
1335
                })
1336
            );
1337
        }
1338

1339
        return [];
3✔
1340
    }
1341

1342
    refreshContent(contentMarkup) {
1343
        this._requestcontent = null;
3✔
1344
        if (!this._loadedStructure) {
3!
1345
            this._requeststructure = null;
3✔
1346
        }
1347
        const newDoc = new DOMParser().parseFromString(contentMarkup, 'text/html');
3✔
1348

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

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

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

1356
        const messages = this._extractMessages($(newDoc));
3✔
1357

1358
        if (messages.length) {
3✔
1359
            setTimeout(() =>
1✔
1360
                messages.forEach(message => {
1✔
1361
                    CMS.API.Messages.open(message);
1✔
1362
                })
1363
            );
1364
        }
1365
        const headDiff = dd.diff(document.head, nodeToObj(newDoc.head));
3✔
1366

1367
        StructureBoard._replaceBodyWithHTML(newDoc.body);
3✔
1368
        dd.apply(document.head, headDiff);
3✔
1369

1370
        toolbar.prependTo(document.body);
3✔
1371
        CMS.API.Toolbar._refreshMarkup(newToolbar);
3✔
1372

1373
        $('.cms-structure-content').scrollTop(structureScrollTop);
3✔
1374
        this._loadedContent = true;
3✔
1375
        this._contentChanged();
3✔
1376
    }
1377

1378
    _contentChanged(messages) {
1379
        Plugin._refreshPlugins();
3✔
1380

1381
        Helpers._getWindow().dispatchEvent(new Event('load'));
3✔
1382
        $(Helpers._getWindow()).trigger('cms-content-refresh');
3✔
1383
        if (messages) {
3!
NEW
1384
            CMS.API.Messages.close();
×
NEW
1385
            if (messages.length) {
×
NEW
1386
                CMS.API.Messages.open({
×
NEW
1387
                    message: messages.map(message => `<p>${message.message}</p>`).join(''),
×
NEW
1388
                    error: messages.some(message => message.level === 'error')
×
1389
                });
1390
            }
1391
        }
1392
    }
1393

1394
    handleAddPlugin(data) {
1395
        if (data.plugin_parent) {
2✔
1396
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1397
        } else {
1398
            // the one in the clipboard is first
1399
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
1✔
1400
        }
1401

1402
        StructureBoard.actualizePlaceholders();
2✔
1403
        Plugin._updateRegistry(data.structure.plugins);
2✔
1404
        data.structure.plugins.forEach(pluginData => {
2✔
1405
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
3✔
1406
        });
1407

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

1413
    handleEditPlugin(data) {
1414
        if (data.plugin_parent) {
2✔
1415
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
1✔
1416
        } else {
1417
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
1✔
1418
        }
1419

1420
        Plugin._updateRegistry(data.structure.plugins);
2✔
1421

1422
        data.structure.plugins.forEach(pluginData => {
2✔
1423
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
2✔
1424
        });
1425

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

1431
    handleDeletePlugin(data) {
1432
        var deletedPluginIds = [data.plugin_id];
2✔
1433
        var draggable = $('.cms-draggable-' + data.plugin_id);
2✔
1434
        var children = draggable.find('.cms-draggable');
2✔
1435
        let parent = draggable.parent().closest('.cms-draggable');
2✔
1436

1437
        if (!parent.length) {
2✔
1438
            parent = draggable.closest('.cms-dragarea');
1✔
1439
        }
1440

1441
        if (children.length) {
2✔
1442
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
1✔
1443
        }
1444

1445
        draggable.remove();
2✔
1446

1447
        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
2✔
1448
        StructureBoard.actualizePlaceholders();
2✔
1449
        deletedPluginIds.forEach(function(pluginId) {
2✔
1450
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
5✔
1451
            remove(
3✔
1452
                CMS._instances,
1453
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
5✔
1454
            );
1455
        });
1456
        return true;
2✔
1457
    }
1458

1459
    handleClearPlaceholder(data) {
1460
        var deletedIds = CMS._instances
1✔
1461
            .filter(instance => {
1462
                if (
3✔
1463
                    instance.options.plugin_id &&
6✔
1464
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
1465
                ) {
1466
                    return true;
2✔
1467
                }
1468
            })
1469
            .map(instance => instance.options.plugin_id);
2✔
1470

1471
        deletedIds.forEach(id => {
1✔
1472
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
5✔
1473
            remove(
2✔
1474
                CMS._instances,
1475
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
5✔
1476
            );
1477

1478
            $(`.cms-draggable-${id}`).remove();
2✔
1479
        });
1480

1481
        StructureBoard.actualizePlaceholders();
1✔
1482
        return true;
1✔
1483
    }
1484

1485
    /**
1486
     * Similar to CMS.Plugin populates globally required
1487
     * variables, that only need querying once, e.g. placeholders.
1488
     *
1489
     * @method _initializeGlobalHandlers
1490
     * @static
1491
     * @private
1492
     */
1493
    static _initializeGlobalHandlers() {
1494
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
77✔
1495
    }
1496

1497
    /**
1498
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
1499
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
1500
     *
1501
     * @function actualizePlaceholders
1502
     * @private
1503
     */
1504
    static actualizePlaceholders() {
1505
        placeholders.each(function() {
156✔
1506
            var placeholder = $(this);
465✔
1507
            var copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');
465✔
1508

1509
            if (
465✔
1510
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
1511
                    .length
1512
            ) {
1513
                placeholder.removeClass('cms-dragarea-empty');
155✔
1514
                copyAll.removeClass('cms-submenu-item-disabled');
155✔
1515
                copyAll.find('> a').removeAttr('aria-disabled');
155✔
1516
            } else {
1517
                placeholder.addClass('cms-dragarea-empty');
310✔
1518
                copyAll.addClass('cms-submenu-item-disabled');
310✔
1519
                copyAll.find('> a').attr('aria-disabled', 'true');
310✔
1520
            }
1521
        });
1522

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

1525
        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
156!
UNCOV
1526
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
×
1527
        }
1528
    }
1529

1530
    /**
1531
     * actualizePluginCollapseStatus
1532
     *
1533
     * @public
1534
     * @param {String} pluginId open the plugin if it should be open
1535
     */
1536
    static actualizePluginCollapseStatus(pluginId) {
1537
        const el = $(`.cms-draggable-${pluginId}`);
1✔
1538
        const open = find(CMS.settings.states, openPluginId => Number(openPluginId) === Number(pluginId));
1✔
1539

1540
        // only add this class to elements which have a draggable area
1541
        // istanbul ignore else
1542
        if (open && el.find('> .cms-draggables').length) {
1✔
1543
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1✔
1544
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1✔
1545
        }
1546
    }
1547

1548
    /**
1549
     * @function actualizePluginsCollapsibleStatus
1550
     * @private
1551
     * @param {jQuery} els lists of plugins (.cms-draggables)
1552
     */
1553
    static actualizePluginsCollapsibleStatus(els) {
1554
        els.each(function() {
9✔
1555
            var childList = $(this);
14✔
1556
            var pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');
14✔
1557

1558
            if (childList.children().length) {
14✔
1559
                pluginDragItem.addClass('cms-dragitem-collapsable');
10✔
1560
                if (childList.children().is(':visible')) {
10!
1561
                    pluginDragItem.addClass('cms-dragitem-expanded');
10✔
1562
                }
1563
            } else {
1564
                pluginDragItem.removeClass('cms-dragitem-collapsable');
4✔
1565
            }
1566
        });
1567
    }
1568

1569
    static _replaceBodyWithHTML(body, forceFullReplace = true) {
×
NEW
1570
        if (forceFullReplace) {
×
1571
            // Resets all events etc.
NEW
1572
            document.body.innerHTML = body.innerHTML;
×
1573
        } else {
1574
            // Mostly small changes to the body, so we can just diff and apply
NEW
1575
            const bodyDiff = dd.diff(document.body, body);
×
1576

NEW
1577
            dd.apply(document.body, bodyDiff);
×
1578
        }
1579
    }
1580

1581
    highlightPluginFromUrl() {
1582
        const hash = window.location.hash;
116✔
1583
        const regex = /cms-plugin-(\d+)/;
116✔
1584

1585
        if (!hash || !hash.match(regex)) {
116!
1586
            return;
116✔
1587
        }
1588

UNCOV
1589
        const pluginId = regex.exec(hash)[1];
×
1590

UNCOV
1591
        if (this._loadedContent) {
×
UNCOV
1592
            Plugin._highlightPluginContent(pluginId, {
×
1593
                seeThrough: true,
1594
                prominent: true,
1595
                delay: 3000
1596
            });
1597
        }
1598
    }
1599

1600
    /**
1601
     * Get's plugins data from markup
1602
     *
1603
     * @method _getPluginDataFromMarkup
1604
     * @private
1605
     * @param {String} markup
1606
     * @param {Array<Number | String>} pluginIds
1607
     * @returns {Array<[String, Object]>}
1608
     */
1609
    static _getPluginDataFromMarkup(markup, pluginIds) {
1610
        return compact(
9✔
1611
            pluginIds.map(pluginId => {
1612
                // oh boy
1613
                const regex = new RegExp(`CMS._plugins.push\\((\\["cms\-plugin\-${pluginId}",[\\s\\S]*?\\])\\)`, 'g');
15✔
1614
                const matches = regex.exec(markup);
15✔
1615
                let settings;
1616

1617
                if (matches) {
15✔
1618
                    try {
5✔
1619
                        settings = JSON.parse(matches[1]);
5✔
1620
                    } catch (e) {
1621
                        settings = false;
2✔
1622
                    }
1623
                } else {
1624
                    settings = false;
10✔
1625
                }
1626

1627
                return settings;
15✔
1628
            })
1629
        );
1630
    }
1631

1632
}
1633

1634
/**
1635
 * Initializes the collapsed/expanded states of dragitems in structureboard.
1636
 *
1637
 * @method _initializeDragItemsStates
1638
 * @static
1639
 * @private
1640
 */
1641
// istanbul ignore next
1642
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
1643
    // removing duplicate entries
1644
    var states = CMS.settings.states || [];
1645
    var sortedArr = states.sort();
1646
    var filteredArray = [];
1647

1648
    for (var i = 0; i < sortedArr.length; i++) {
1649
        if (sortedArr[i] !== sortedArr[i + 1]) {
1650
            filteredArray.push(sortedArr[i]);
1651
        }
1652
    }
1653
    CMS.settings.states = filteredArray;
1654

1655
    // loop through the items
1656
    $.each(CMS.settings.states, function(index, id) {
1657
        var el = $('.cms-draggable-' + id);
1658

1659
        // only add this class to elements which have immediate children
1660
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
1661
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
1662
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
1663
        }
1664
    });
1665
};
1666

1667
// shorthand for jQuery(document).ready();
1668
$(StructureBoard._initializeGlobalHandlers);
1✔
1669

1670
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