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

divio / django-cms / #30128

15 Nov 2025 10:40AM UTC coverage: 89.757% (-0.06%) from 89.818%
#30128

push

travis-ci

web-flow
Merge 5306237b1 into d306559f3

1335 of 2146 branches covered (62.21%)

14 of 40 new or added lines in 3 files covered. (35.0%)

15 existing lines in 2 files now uncovered.

9218 of 10270 relevant lines covered (89.76%)

11.15 hits per line

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

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

5
import ChangeTracker from './cms.changetracker';
6
import keyboard from './keyboard';
7

8
import $ from 'jquery';
9
import './jquery.transition';
10
import { trap, untrap } from './trap';
11

12
import { Helpers, KEYS } from './cms.base';
13
import { showLoader, hideLoader } from './loader';
14

15
var previousKeyboardContext;
16
var previouslyFocusedElement;
17

18
/**
19
 * The modal is triggered via API calls from the backend either
20
 * through the toolbar navigation or from plugins. The APIs allow to
21
 * open content from a url (iframe) or inject html directly.
22
 *
23
 * @class Modal
24
 * @namespace CMS
25
 */
26
class Modal {
27
    constructor(options) {
28
        this.options = $.extend(true, {}, Modal.options, options);
163✔
29

30
        // elements
31
        this._setupUI();
163✔
32

33
        // states and events
34
        this.click = 'click.cms.modal';
163✔
35
        this.pointerDown = 'pointerdown.cms.modal contextmenu.cms.modal';
163✔
36
        this.pointerUp = 'pointerup.cms.modal pointercancel.cms.modal';
163✔
37
        this.pointerMove = 'pointermove.cms.modal';
163✔
38
        this.doubleClick = 'dblclick.cms.modal';
163✔
39
        this.touchEnd = 'touchend.cms.modal';
163✔
40
        this.keyUp = 'keyup.cms.modal';
163✔
41
        this.maximized = false;
163✔
42
        this.minimized = false;
163✔
43
        this.triggerMaximized = false;
163✔
44
        this.saved = false;
163✔
45

46
        this._beforeUnloadHandler = this._beforeUnloadHandler.bind(this);
163✔
47
    }
48

49
    /**
50
     * Stores all jQuery references within `this.ui`.
51
     *
52
     * @method _setupUI
53
     * @private
54
     */
55
    _setupUI() {
56
        var modal = $('.cms-modal');
163✔
57

58
        this.ui = {
163✔
59
            modal: modal,
60
            body: $('html'),
61
            window: $(window),
62
            toolbarLeftPart: $('.cms-toolbar-left'),
63
            minimizeButton: modal.find('.cms-modal-minimize'),
64
            maximizeButton: modal.find('.cms-modal-maximize'),
65
            title: modal.find('.cms-modal-title'),
66
            titlePrefix: modal.find('.cms-modal-title-prefix'),
67
            titleSuffix: modal.find('.cms-modal-title-suffix'),
68
            resize: modal.find('.cms-modal-resize'),
69
            breadcrumb: modal.find('.cms-modal-breadcrumb'),
70
            closeAndCancel: modal.find('.cms-modal-close, .cms-modal-cancel'),
71
            modalButtons: modal.find('.cms-modal-buttons'),
72
            modalBody: modal.find('.cms-modal-body'),
73
            frame: modal.find('.cms-modal-frame'),
74
            shim: modal.find('.cms-modal-shim')
75
        };
76
    }
77

78
    /**
79
     * Sets up all the event handlers, such as maximize/minimize and resizing.
80
     *
81
     * @method _events
82
     * @private
83
     */
84
    _events() {
85
        var that = this;
34✔
86

87
        // modal behaviours
88
        this.ui.minimizeButton
34✔
89
            .off(this.click + ' ' + this.touchEnd + ' ' + this.keyUp)
90
            .on(this.click + ' ' + this.touchEnd + ' ' + this.keyUp, function(e) {
91
                if (e.type !== 'keyup' || (e.type === 'keyup' && e.keyCode === KEYS.ENTER)) {
4!
92
                    e.preventDefault();
4✔
93
                    that.minimize();
4✔
94
                }
95
            });
96
        this.ui.maximizeButton
34✔
97
            .off(this.click + ' ' + this.touchEnd + ' ' + this.keyUp)
98
            .on(this.click + ' ' + this.touchEnd + ' ' + this.keyUp, function(e) {
99
                if (e.type !== 'keyup' || (e.type === 'keyup' && e.keyCode === KEYS.ENTER)) {
4!
100
                    e.preventDefault();
4✔
101
                    that.maximize();
4✔
102
                }
103
            });
104

105
        this.ui.title.off(this.pointerDown).on(this.pointerDown, function(e) {
34✔
106
            e.preventDefault();
4✔
107
            that._startMove(e);
4✔
108
        });
109
        this.ui.title.off(this.doubleClick).on(this.doubleClick, function() {
34✔
110
            that.maximize();
2✔
111
        });
112

113
        this.ui.resize.off(this.pointerDown).on(this.pointerDown, function(e) {
34✔
114
            e.preventDefault();
4✔
115
            that._startResize(e);
4✔
116
        });
117

118
        this.ui.closeAndCancel
34✔
119
            .off(this.click + ' ' + this.touchEnd + ' ' + this.keyUp)
120
            .on(this.click + ' ' + this.touchEnd + ' ' + this.keyUp, function(e) {
121
                if (e.type !== 'keyup' || (e.type === 'keyup' && e.keyCode === KEYS.ENTER)) {
4!
122
                    e.preventDefault();
4✔
123
                    that._cancelHandler();
4✔
124
                }
125
            });
126

127
        // elements within the window
128
        this.ui.breadcrumb.off(this.click, 'a').on(this.click, 'a', function(e) {
34✔
129
            e.preventDefault();
2✔
130
            that._changeIframe($(this));
2✔
131
        });
132
    }
133

134
    /**
135
     * Opens the modal either in an iframe or renders markup.
136
     *
137
     * @method open
138
     * @chainable
139
     * @param {Object} opts either `opts.url` or `opts.html` are required
140
     * @param {Object[]} [opts.breadcrumbs] collection of breadcrumb items
141
     * @param {String|HTMLNode|jQuery} [opts.html] html markup to render
142
     * @param {String} [opts.title] modal window main title (bold)
143
     * @param {String} [opts.subtitle] modal window secondary title (normal)
144
     * @param {String} [opts.url] url to render iframe, takes precedence over `opts.html`
145
     * @param {Number} [opts.width] sets the width of the modal
146
     * @param {Number} [opts.height] sets the height of the modal
147
     * @returns {Class} this
148
     */
149
    open(opts) {
150
        // setup internals
151
        if (!((opts && opts.url) || (opts && opts.html))) {
35✔
152
            throw new Error('The arguments passed to "open" were invalid.');
4✔
153
        }
154

155
        // We have to rebind events every time we open a modal
156
        // because the event handlers contain references to the instance
157
        // and since we reuse the same markup we need to update
158
        // that instance reference every time.
159
        this._events();
31✔
160

161
        Helpers.dispatchEvent('modal-load', { instance: this });
31✔
162
        // // trigger the event also on the dom element,
163
        // // because if we load another modal while one is already open
164
        // // the older instance won't receive any updates
165
        // this.ui.modal.trigger('cms.modal.load');
166

167
        // common elements state
168
        this.ui.resize.toggle(this.options.resizable);
31✔
169
        this.ui.minimizeButton.toggle(this.options.minimizable);
31✔
170
        this.ui.maximizeButton.toggle(this.options.maximizable);
31✔
171

172
        const position = this._calculateNewPosition(opts);
31✔
173

174
        this.ui.maximizeButton.removeClass('cms-modal-maximize-active');
31✔
175
        this.maximized = false;
31✔
176

177
        // because a new instance is called, we have to ensure minimized state is removed #3620
178
        if (this.ui.body.hasClass('cms-modal-minimized')) {
31✔
179
            this.minimized = true;
1✔
180
            this.minimize();
1✔
181
        }
182

183
        // clear elements
184
        this.ui.modalButtons.empty();
31✔
185
        this.ui.breadcrumb.empty();
31✔
186

187
        // remove class from modal when no breadcrumbs is rendered
188
        this.ui.modal.removeClass('cms-modal-has-breadcrumb');
31✔
189

190
        // hide tooltip
191
        CMS.API.Tooltip.hide();
31✔
192

193
        // redirect to iframe rendering if url is provided
194
        if (opts.url) {
31✔
195
            this._loadIframe({
1✔
196
                url: opts.url,
197
                title: opts.title,
198
                breadcrumbs: opts.breadcrumbs
199
            });
200
        } else {
201
            // if url is not provided we go for html
202
            this._loadMarkup({
30✔
203
                html: opts.html,
204
                title: opts.title,
205
                subtitle: opts.subtitle
206
            });
207
        }
208

209
        Helpers.dispatchEvent('modal-loaded', { instance: this });
31✔
210

211
        var currentContext = keyboard.getContext();
31✔
212

213
        if (currentContext !== 'modal') {
31✔
214
            previousKeyboardContext = keyboard.getContext();
9✔
215
            previouslyFocusedElement = $(document.activeElement);
9✔
216
        }
217

218
        // display modal
219
        this._show(
31✔
220
            $.extend(
221
                {
222
                    duration: this.options.modalDuration
223
                },
224
                position
225
            )
226
        );
227

228
        keyboard.setContext('modal');
31✔
229
        return this;
31✔
230
    }
231

232
    /**
233
     * Calculates coordinates and dimensions for modal placement
234
     *
235
     * @method _calculateNewPosition
236
     * @private
237
     * @param {Object} [opts]
238
     * @param {Number} [opts.width] desired width of the modal
239
     * @param {Number} [opts.height] desired height of the modal
240
     * @returns {Object}
241
     */
242
    // eslint-disable-next-line complexity
243
    _calculateNewPosition(opts) {
244
        // lets set the modal width and height to the size of the browser
245
        var widthOffset = 300; // adds margin left and right
48✔
246
        var heightOffset = 300; // adds margin top and bottom;
48✔
247
        var screenWidth = this.ui.window.width();
48✔
248
        var screenHeight = this.ui.window.height();
48✔
249
        var modalWidth = opts.width || this.options.minWidth;
48✔
250
        var modalHeight = opts.height || this.options.minHeight;
48✔
251
        // screen width and height calculation, WC = width
252
        var screenWidthCalc = screenWidth >= modalWidth + widthOffset;
48✔
253
        var screenHeightCalc = screenHeight >= modalHeight + heightOffset;
48✔
254

255
        var width = screenWidthCalc && !opts.width ? screenWidth - widthOffset : modalWidth;
48✔
256
        var height = screenHeightCalc && !opts.height ? screenHeight - heightOffset : modalHeight;
48✔
257

258
        var currentLeft = this.ui.modal.css('left');
48✔
259
        var currentTop = this.ui.modal.css('top');
48✔
260
        var newLeft;
261
        var newTop;
262

263
        // jquery made me do it
264
        if (currentLeft === '50%') {
48✔
265
            currentLeft = screenWidth / 2;
27✔
266
        }
267
        if (currentTop === '50%') {
48✔
268
            currentTop = screenHeight / 2;
27✔
269
        }
270

271
        currentTop = parseInt(currentTop, 10);
48✔
272
        currentLeft = parseInt(currentLeft, 10);
48✔
273

274
        // if new width/height go out of the screen - reset position to center of screen
275
        if (
48✔
276
            width / 2 + currentLeft > screenWidth ||
174✔
277
            height / 2 + currentTop > screenHeight ||
278
            currentLeft - width / 2 < 0 ||
279
            currentTop - height / 2 < 0
280
        ) {
281
            newLeft = screenWidth / 2;
13✔
282
            newTop = screenHeight / 2;
13✔
283
        }
284

285
        // in case, the modal is larger than the window, we trigger fullscreen mode
286
        if (width >= screenWidth || height >= screenHeight) {
48✔
287
            this.triggerMaximized = true;
3✔
288
        }
289

290
        return {
48✔
291
            width: width,
292
            height: height,
293
            top: newTop,
294
            left: newLeft
295
        };
296
    }
297

298
    /**
299
     * Animation helper for opening the sideframe.
300
     *
301
     * @method _show
302
     * @private
303
     * @param {Object} opts
304
     * @param {Number} opts.width width of the modal
305
     * @param {Number} opts.height height of the modal
306
     * @param {Number} opts.left left in px of the center of the modal
307
     * @param {Number} opts.top top in px of the center of the modal
308
     * @param {Number} opts.duration speed of opening, ms (not really used yet)
309
     */
310
    _show(opts) {
311
        // we need to position the modal in the center
312
        var that = this;
41✔
313
        var width = opts.width;
41✔
314
        var height = opts.height;
41✔
315
        var speed = opts.duration;
41✔
316
        var top = opts.top;
41✔
317
        var left = opts.left;
41✔
318

319
        if (this.ui.modal.hasClass('cms-modal-open')) {
41✔
320
            this.ui.modal.addClass('cms-modal-morphing');
1✔
321
        }
322

323
        this.ui.modal.css({
41✔
324
            display: 'block',
325
            width: width,
326
            height: height,
327
            top: top,
328
            left: left,
329
            'margin-left': -(width / 2),
330
            'margin-top': -(height / 2)
331
        });
332
        // setImmediate is required to go into the next frame
333
        setTimeout(function() {
41✔
334
            that.ui.modal.addClass('cms-modal-open');
41✔
335
        }, 0);
336

337
        this.ui.modal
41✔
338
            .one('cmsTransitionEnd', function() {
339
                that.ui.modal.removeClass('cms-modal-morphing');
37✔
340
                that.ui.modal.css({
37✔
341
                    'margin-left': -(width / 2),
342
                    'margin-top': -(height / 2)
343
                });
344

345
                // check if we should maximize
346
                if (that.triggerMaximized) {
37✔
347
                    that.maximize();
1✔
348
                }
349

350
                // changed locked status to allow other modals again
351
                CMS.API.locked = false;
37✔
352
                Helpers.dispatchEvent('modal-shown', { instance: that });
37✔
353
            })
354
            .emulateTransitionEnd(speed);
355

356
        // add esc close event
357
        this.ui.body.off('keydown.cms.close').on('keydown.cms.close', function(e) {
41✔
358
            if (e.keyCode === KEYS.ESC && that.options.closeOnEsc) {
6✔
359
                e.stopPropagation();
3✔
360
                if (that._confirmDirtyEscCancel()) {
3✔
361
                    that._cancelHandler();
2✔
362
                }
363
            }
364
        });
365

366
        // set focus to modal
367
        this.ui.modal.focus();
41✔
368
    }
369

370
    /**
371
     * Closes the current instance.
372
     *
373
     * @method close
374
     * @returns {Boolean|void}
375
     */
376
    close() {
377
        var event = Helpers.dispatchEvent('modal-close', { instance: this });
10✔
378

379
        if (event.isDefaultPrevented()) {
10✔
380
            return false;
1✔
381
        }
382

383
        Helpers._getWindow().removeEventListener('beforeunload', this._beforeUnloadHandler);
9✔
384

385
        // handle refresh option
386
        if (this.options.onClose) {
9✔
387
            Helpers.reloadBrowser(this.options.onClose, false);
1✔
388
        }
389
        untrap(this.ui.body[0]);
9✔
390
        keyboard.setContext(previousKeyboardContext);
9✔
391
        try {
9✔
392
            previouslyFocusedElement.focus();
9✔
393
        } catch {}
394

395
        this._hide({
9✔
396
            duration: this.options.modalDuration / 2
397
        });
398
    }
399

400
    /**
401
     * Animation helper for closing the iframe.
402
     *
403
     * @method _hide
404
     * @private
405
     * @param {Object} opts
406
     * @param {Number} [opts.duration=this.options.modalDuration] animation duration
407
     */
408
    _hide(opts) {
409
        var that = this;
16✔
410
        var duration = this.options.modalDuration;
16✔
411

412
        if (opts && opts.duration) {
16✔
413
            duration = opts.duration;
9✔
414
        }
415

416
        this.ui.frame.empty();
16✔
417
        this.ui.modalBody.removeClass('cms-loader');
16✔
418
        this.ui.modal.removeClass('cms-modal-open');
16✔
419
        this.ui.modal
16✔
420
            .one('cmsTransitionEnd', function() {
421
                that.ui.modal.css('display', 'none');
9✔
422
            })
423
            .emulateTransitionEnd(duration);
424

425
        // reset maximize or minimize states for #3111
426
        setTimeout(function() {
16✔
427
            if (that.minimized) {
13✔
428
                that.minimize();
1✔
429
            }
430
            if (that.maximized) {
13✔
431
                that.maximize();
1✔
432
            }
433
            hideLoader();
13✔
434
            Helpers.dispatchEvent('modal-closed', { instance: that });
13✔
435
        }, this.options.duration);
436

437
        this.ui.body.off('keydown.cms.close');
16✔
438
    }
439

440
    /**
441
     * Minimizes the modal onto the toolbar.
442
     *
443
     * @method minimize
444
     * @returns {Boolean|void}
445
     */
446
    minimize() {
447
        var MINIMIZED_OFFSET = 50;
11✔
448

449
        // cancel action if maximized
450
        if (this.maximized) {
11✔
451
            return false;
1✔
452
        }
453

454
        if (this.minimized === false) {
10✔
455
            // save initial state
456
            this.ui.modal.data('css', this.ui.modal.css(['left', 'top', 'margin-left', 'margin-top']));
5✔
457

458
            // minimize
459
            this.ui.body.addClass('cms-modal-minimized');
5✔
460
            this.ui.modal.css({
5✔
461
                left: this.ui.toolbarLeftPart.outerWidth(true) + MINIMIZED_OFFSET
462
            });
463

464
            this.minimized = true;
5✔
465
        } else {
466
            // maximize
467
            this.ui.body.removeClass('cms-modal-minimized');
5✔
468
            this.ui.modal.css(this.ui.modal.data('css'));
5✔
469

470
            this.minimized = false;
5✔
471
        }
472
    }
473

474
    /**
475
     * Maximizes the window according to the browser size.
476
     *
477
     * @method maximize
478
     * @returns {Boolean|void}
479
     */
480
    maximize() {
481
        // cancel action when minimized
482
        if (this.minimized) {
11✔
483
            return false;
1✔
484
        }
485

486
        if (this.maximized === false) {
10✔
487
            // save initial state
488
            this.ui.modal.data(
5✔
489
                'css',
490
                this.ui.modal.css(['left', 'top', 'margin-left', 'margin-top', 'width', 'height'])
491
            );
492

493
            this.ui.body.addClass('cms-modal-maximized');
5✔
494

495
            this.maximized = true;
5✔
496
            Helpers.dispatchEvent('modal-maximized', { instance: this });
5✔
497
        } else {
498
            // minimize
499
            this.ui.body.removeClass('cms-modal-maximized');
5✔
500
            this.ui.modal.css(this.ui.modal.data('css'));
5✔
501

502
            this.maximized = false;
5✔
503
            Helpers.dispatchEvent('modal-restored', { instance: this });
5✔
504
        }
505
    }
506

507
    /**
508
     * Initiates the start move event from `_events`.
509
     *
510
     * @method _startMove
511
     * @private
512
     * @param {Object} pointerEvent passes starting event
513
     * @returns {Boolean|void}
514
     */
515
    _startMove(pointerEvent) {
516
        // cancel if maximized or minimized
517
        if (this.maximized || this.minimized) {
9✔
518
            return false;
2✔
519
        }
520

521
        var that = this;
7✔
522
        var position = this.ui.modal.position();
7✔
523
        var left;
524
        var top;
525

526
        this.ui.shim.show();
7✔
527

528
        // create event for stopping
529
        this.ui.body.on(this.pointerUp, function(e) {
7✔
530
            that._stopMove(e);
2✔
531
        });
532

533
        this.ui.body
7✔
534
            .on(this.pointerMove, function(e) {
535
                left = position.left - (pointerEvent.originalEvent.pageX - e.originalEvent.pageX);
1✔
536
                top = position.top - (pointerEvent.originalEvent.pageY - e.originalEvent.pageY);
1✔
537

538
                that.ui.modal.css({
1✔
539
                    left: left,
540
                    top: top
541
                });
542
            });
543

544
        // Disable touch actions during modal move
545
        document.body.style.touchAction = 'none';
7✔
546
    }
547

548
    /**
549
     * Initiates the stop move event from `_startMove`.
550
     *
551
     * @method _stopMove
552
     * @private
553
     */
554
    _stopMove() {
555
        this.ui.shim.hide();
3✔
556
        this.ui.body.off(this.pointerMove + ' ' + this.pointerUp);
3✔
557
        // Re-enable touch actions
558
        document.body.style.touchAction = '';
3✔
559
    }
560

561
    /**
562
     * Initiates the start resize event from `_events`.
563
     *
564
     * @method _startResize
565
     * @private
566
     * @param {Object} pointerEvent passes starting event
567
     * @returns {Boolean|void}
568
     */
569
    _startResize(pointerEvent) {
570
        // cancel if in fullscreen
571
        if (this.maximized) {
9✔
572
            return false;
1✔
573
        }
574
        // continue
575
        var that = this;
8✔
576
        var width = this.ui.modal.width();
8✔
577
        var height = this.ui.modal.height();
8✔
578
        var modalLeft = this.ui.modal.position().left;
8✔
579
        var modalTop = this.ui.modal.position().top;
8✔
580
        var resizeDir = this.ui.resize.css('direction') === 'rtl' ? -1 : +1;
8!
581

582
        // create event for stopping
583
        this.ui.body.on(this.pointerUp, function(e) {
8✔
584
            that._stopResize(e);
2✔
585
        });
586

587
        this.ui.shim.show();
8✔
588

589
        this.ui.body
8✔
590
            .on(this.pointerMove, function(e) {
591
                var mvX = pointerEvent.originalEvent.pageX - e.originalEvent.pageX;
3✔
592
                var mvY = pointerEvent.originalEvent.pageY - e.originalEvent.pageY;
3✔
593
                var w = width - resizeDir * mvX * 2;
3✔
594
                var h = height - mvY * 2;
3✔
595
                var wMin = that.options.minWidth;
3✔
596
                var hMin = that.options.minHeight;
3✔
597
                var left = resizeDir * mvX + modalLeft;
3✔
598
                var top = mvY + modalTop;
3✔
599

600
                // add some limits
601
                if (w <= wMin) {
3✔
602
                    w = wMin;
1✔
603
                    left = modalLeft + width / 2 - w / 2;
1✔
604
                }
605
                if (h <= hMin) {
3✔
606
                    h = hMin;
1✔
607
                    top = modalTop + height / 2 - h / 2;
1✔
608
                }
609

610
                // set centered animation
611
                that.ui.modal.css({
3✔
612
                    width: w,
613
                    height: h,
614
                    left: left,
615
                    top: top
616
                });
617
            });
618

619
        // Disable touch actions during modal resize
620
        document.body.style.touchAction = 'none';
8✔
621
    }
622

623
    /**
624
     * Initiates the stop resize event from `_startResize`.
625
     *
626
     * @method _stopResize
627
     * @private
628
     */
629
    _stopResize() {
630
        this.ui.shim.hide();
3✔
631
        this.ui.body.off(this.pointerMove + ' ' + this.pointerUp);
3✔
632
        // Re-enable touch actions
633
        document.body.style.touchAction = '';
3✔
634
    }
635

636
    /**
637
     * Sets the breadcrumb inside the modal.
638
     *
639
     * @method _setBreadcrumb
640
     * @private
641
     * @param {Object[]} breadcrumbs renderes breadcrumb on modal
642
     * @returns {Boolean|void}
643
     */
644
    _setBreadcrumb(breadcrumbs) {
645
        var crumb = '';
11✔
646
        var template = '<a href="{1}" class="{2}"><span>{3}</span></a>';
11✔
647

648
        // cancel if there is no breadcrumbs)
649
        if (!breadcrumbs || breadcrumbs.length <= 1) {
11✔
650
            return false;
3✔
651
        }
652
        if (!breadcrumbs[0].title) {
8✔
653
            return false;
1✔
654
        }
655

656
        // add class to modal
657
        this.ui.modal.addClass('cms-modal-has-breadcrumb');
7✔
658

659
        // load breadcrumbs
660
        $.each(breadcrumbs, function(index, item) {
7✔
661
            // check if the item is the last one
662
            var last = index >= breadcrumbs.length - 1 ? 'active' : '';
21✔
663

664
            // render breadcrumbs
665
            crumb += template.replace('{1}', item.url).replace('{2}', last).replace('{3}', item.title);
21✔
666
        });
667

668
        // attach elements
669
        this.ui.breadcrumb.html(crumb);
7✔
670
    }
671

672
    /**
673
     * Sets the buttons inside the modal.
674
     *
675
     * @method _setButtons
676
     * @private
677
     * @param {jQuery} iframe loaded iframe element
678
     */
679
    _setButtons(iframe) {
680
        var djangoSuit = iframe.contents().find('.suit-columns').length > 0;
4✔
681
        var that = this;
4✔
682
        var group = $('<div class="cms-modal-item-buttons"></div>');
4✔
683
        var render = $('<div class="cms-modal-buttons-inner"></div>');
4✔
684
        var cancel = $('<a href="#" class="cms-btn">' + CMS.config.lang.cancel + '</a>');
4✔
685
        var row;
686
        var tmp;
687

688
        // istanbul ignore if
689
        if (djangoSuit) {
4✔
690
            row = iframe.contents().find('.save-box:eq(0)');
691
        } else {
692
            row = iframe.contents().find('.submit-row:eq(0)');
4✔
693
        }
694
        var form = iframe.contents().find('form');
4✔
695

696
        // avoids conflict between the browser's form validation and Django's validation
697
        form.on('submit', function() {
4✔
698
            // default submit button was clicked
699
            // meaning, if you have save - it should close the iframe,
700
            // if you hit save and continue editing it should be default form behaviour
701
            if (that.hideFrame) {
3✔
702
                that.ui.modal.find('.cms-modal-frame iframe').hide();
2✔
703
                // page has been saved, run checkup
704
                that.saved = true;
2✔
705
            }
706
        });
707
        var buttons = row.find('input, a, button');
4✔
708

709
        // these are the buttons _inside_ the iframe
710
        // we need to listen to this click event to support submitting
711
        // a form by pressing enter inside of a field
712
        // click is actually triggered by submit
713
        buttons.on('click', function() {
4✔
714
            if ($(this).hasClass('default')) {
2✔
715
                that.hideFrame = true;
1✔
716
            }
717
        });
718

719
        // hide all submit-rows
720
        iframe.contents().find('.submit-row').hide();
4✔
721

722
        // if there are no given buttons within the submit-row area
723
        // scan deeper within the form itself
724
        // istanbul ignore next
725
        if (!buttons.length) {
726
            row = iframe.contents().find('body:not(.change-list) #content form:eq(0)');
727
            buttons = row.find('input[type="submit"], button[type="submit"]');
728
            buttons.addClass('deletelink').hide();
729
        }
730

731
        // loop over input buttons
732
        buttons.each(function(index, btn) {
4✔
733
            var item = $(btn);
16✔
734

735
            item.attr('data-rel', '_' + index);
16✔
736

737
            // cancel if item is a hidden input
738
            if (item.attr('type') === 'hidden') {
16✔
739
                return false;
3✔
740
            }
741

742
            var title = item.attr('value') || item.text();
13✔
743
            var cls = 'cms-btn';
13✔
744

745
            if (item.is('button')) {
13✔
746
                title = item.text();
4✔
747
            }
748

749
            // set additional special css classes
750
            if (item.hasClass('default')) {
13✔
751
                cls = 'cms-btn cms-btn-action';
4✔
752
            }
753
            if (item.hasClass('deletelink')) {
13✔
754
                cls = 'cms-btn cms-btn-caution';
3✔
755
            }
756

757
            // Safely build the element using jQuery APIs to escape title and class
758
            var el = $('<a href="#"></a>');
13✔
759

760
            el.addClass(cls);
13✔
761
            var itemClasses = item.attr('class');
13✔
762

763
            if (itemClasses) {
13✔
764
                el.addClass(itemClasses);
7✔
765
            }
766
            el.text(title);
13✔
767

768
            el.on(that.click + ' ' + that.touchEnd, function(e) {
13✔
769
                e.preventDefault();
5✔
770

771
                if (item.is('a')) {
5✔
772
                    that._loadIframe({
2✔
773
                        url: Helpers.updateUrlWithPath(item.prop('href')),
774
                        name: title
775
                    });
776
                }
777

778
                // trigger only when blue action buttons are triggered
779
                if (item.hasClass('default') || item.hasClass('deletelink')) {
5✔
780
                    // hide iframe when using buttons other than submit
781
                    if (item.hasClass('default')) {
3✔
782
                        // submit button uses the form's submit event
783
                        that.hideFrame = true;
2✔
784
                    } else {
785
                        that.ui.modal.find('.cms-modal-frame iframe').hide();
1✔
786
                        // page has been saved or deleted, run checkup
787
                        that.saved = true;
1✔
788
                    }
789
                }
790

791
                if (item.is('input') || item.is('button')) {
5✔
792
                    that.ui.modalBody.addClass('cms-loader');
3✔
793
                    var frm = item[0].form;
3✔
794

795
                    // In Firefox with 1Password extension installed (FF 45 1password 4.5.6 at least)
796
                    // the item[0].click() doesn't work, which notably breaks
797
                    // deletion of the plugin. Workaround is that if the clicked button
798
                    // is the only button in the form - submit a form, otherwise
799
                    // click on the button
800
                    if (frm.querySelectorAll('button, input[type="button"], input[type="submit"]').length > 1) {
3✔
801
                        // we need to use native `.click()` event specifically
802
                        // as we are inside an iframe and magic is happening
803
                        item[0].click();
2✔
804
                    } else {
805
                        // have to dispatch native submit event so all the submit handlers
806
                        // can be fired, see #5590
807
                        var evt = new CustomEvent('submit', { bubbles: false, cancelable: true });
1✔
808

809
                        if (frm.dispatchEvent(evt)) {
1!
810
                            // triggering submit event in webkit based browsers won't
811
                            // actually submit the form, while in Gecko-based ones it
812
                            // will and calling frm.submit() would throw NS_ERROR_UNEXPECTED
UNCOV
813
                            try {
×
UNCOV
814
                                frm.submit();
×
815
                            } catch {}
816
                        }
817
                    }
818
                }
819
            });
820
            el.wrap(group);
13✔
821

822
            // append element
823
            render.append(el.parent());
13✔
824
        });
825

826
        // manually add cancel button at the end
827
        cancel.on(that.click, function(e) {
4✔
828
            e.preventDefault();
1✔
829
            that._cancelHandler();
1✔
830
        });
831
        cancel.wrap(group);
4✔
832
        render.append(cancel.parent());
4✔
833

834
        // prepare groups
835
        render.find('.cms-btn-group').unwrap();
4✔
836
        tmp = render.find('.cms-btn-group').clone(true, true);
4✔
837
        render.find('.cms-btn-group').remove();
4✔
838
        render.append(tmp.wrapAll(group.clone().addClass('cms-modal-item-buttons-left')).parent());
4✔
839

840
        // render buttons
841
        this.ui.modalButtons.html(render);
4✔
842
    }
843

844
    /**
845
     * Version where the modal loads an iframe.
846
     *
847
     * @method _loadIframe
848
     * @private
849
     * @param {Object} opts
850
     * @param {String} opts.url url to render iframe, takes presedence over opts.html
851
     * @param {Object[]} [opts.breadcrumbs] collection of breadcrumb items
852
     * @param {String} [opts.title] modal window main title (bold)
853
     */
854
    _loadIframe(opts) {
855
        var that = this;
30✔
856
        const SHOW_LOADER_TIMEOUT = 500;
30✔
857

858
        opts.url = Helpers.makeURL(opts.url);
30✔
859
        opts.title = opts.title || '';
30✔
860
        opts.breadcrumbs = opts.breadcrumbs || '';
30✔
861

862
        showLoader();
30✔
863

864
        // set classes
865
        this.ui.modal.removeClass('cms-modal-markup');
30✔
866
        this.ui.modal.addClass('cms-modal-iframe');
30✔
867

868
        // we need to render the breadcrumb
869
        this._setBreadcrumb(opts.breadcrumbs);
30✔
870

871
        // now refresh the content
872
        var holder = this.ui.frame;
30✔
873
        var iframe = $('<iframe tabindex="0" src="' + opts.url + '" class="" frameborder="0" />');
30✔
874

875
        // set correct title
876
        var titlePrefix = this.ui.titlePrefix;
30✔
877
        var titleSuffix = this.ui.titleSuffix;
30✔
878

879
        iframe.css('visibility', 'hidden');
30✔
880
        titlePrefix.text(opts.title || '');
30✔
881
        titleSuffix.text('');
30✔
882

883
        // ensure previous iframe is hidden
884
        holder.find('iframe').css('visibility', 'hidden');
30✔
885
        const loaderTimeout = setTimeout(() => that.ui.modalBody.addClass('cms-loader'), SHOW_LOADER_TIMEOUT);
30✔
886

887
        // attach load event for iframe to prevent flicker effects
888
        // eslint-disable-next-line complexity
889
        iframe.on('load', function() {
30✔
890
            clearTimeout(loaderTimeout);
27✔
891
            var messages;
892
            var messageList;
893
            var contents;
894
            var body;
895
            var innerTitle;
896
            var bc;
897

898
            // check if iframe can be accessed
899
            try {
27✔
900
                contents = iframe.contents();
27✔
901
                body = contents.find('body');
26✔
902
            } catch {
903
                CMS.API.Messages.open({
1✔
904
                    message: '<strong>' + CMS.config.lang.errorLoadingEditForm + '</strong>',
905
                    error: true,
906
                    delay: 0
907
                });
908
                that.close();
1✔
909
                return;
1✔
910
            }
911

912
            // trap focus within modal
913
            trap(body[0]);
26✔
914

915
            // check if we are redirected - should only happen after successful form submission
916
            const redirect = body.find('a.cms-view-new-object').attr('href');
26✔
917

918
            if (redirect) {
26!
UNCOV
919
                Helpers.reloadBrowser(redirect, false);
×
UNCOV
920
                return true;
×
921
            }
922

923
            // If the response contains the close-frame (and potentially the data bridge),
924
            // the form was saved successfully
925
            that.saved = that.saved || body.hasClass('cms-close-frame');
26✔
926

927
            // tabindex is required for keyboard navigation
928
            // body.attr('tabindex', '0');
929
            iframe.on('focus', function() {
26✔
930
                if (this.contentWindow) {
1!
931
                    this.contentWindow.focus();
1✔
932
                }
933
            });
934

935
            Modal._setupCtrlEnterSave(document);
26✔
936
            // istanbul ignore else
937
            if (iframe[0].contentWindow && iframe[0].contentWindow.document) {
26✔
938
                Modal._setupCtrlEnterSave(iframe[0].contentWindow.document);
26✔
939
            }
940
            // for ckeditor we need to go deeper
941
            // istanbul ignore next
942
            if (iframe[0].contentWindow && iframe[0].contentWindow.CMS && iframe[0].contentWindow.CMS.CKEditor) {
943
                $(iframe[0].contentWindow.document).ready(function() {
944
                    // setTimeout is required to battle CKEditor initialisation
945
                    setTimeout(function() {
946
                        var editor = iframe[0].contentWindow.CMS.CKEditor.editor;
947

948
                        if (editor) {
949
                            editor.on('instanceReady', function(e) {
950
                                Modal._setupCtrlEnterSave(
951
                                    $(e.editor.container.$).find('iframe')[0].contentWindow.document
952
                                );
953
                            });
954
                        }
955
                    }, 100); // eslint-disable-line
956
                });
957
            }
958

959
            var saveSuccess = Boolean(contents.find('.messagelist :not(".error")').length);
26✔
960

961
            // in case message didn't appear, assume that admin page is actually a success
962
            // istanbul ignore if
963
            if (!saveSuccess) {
26✔
964
                saveSuccess =
965
                    Boolean(contents.find('.dashboard #content-main').length) &&
966
                    !contents.find('.messagelist .error').length;
967
            }
968

969
            // show messages in toolbar if provided
970
            messageList = contents.find('.messagelist');
26✔
971
            messages = messageList.find('li');
26✔
972
            if (messages.length) {
26✔
973
                CMS.API.Messages.open({
10✔
974
                    message: messages.eq(0).html()
975
                });
976
            }
977
            messageList.remove();
26✔
978

979
            // inject css class
980
            body.addClass('cms-admin cms-admin-modal');
26✔
981

982
            // hide loaders
983
            that.ui.modalBody.removeClass('cms-loader');
26✔
984
            hideLoader();
26✔
985

986
            // determine if we should close the modal or reload
987
            if (messages.length && that.enforceReload) {
26!
UNCOV
988
                that.ui.modalBody.addClass('cms-loader');
×
UNCOV
989
                showLoader();
×
UNCOV
990
                Helpers.reloadBrowser();
×
991
            }
992
            if (messages.length && that.enforceClose) {
26✔
993
                that.close();
1✔
994
                return false;
1✔
995
            }
996

997
            // adding django hacks
998
            contents.find('.viewsitelink').attr('target', '_top');
25✔
999

1000
            // set modal buttons
1001
            that._setButtons($(this));
25✔
1002

1003
            // when an error occurs, reset the saved status so the form can be checked and validated again
1004
            if (
25✔
1005
                contents.find('.errornote').length ||
74✔
1006
                contents.find('.errorlist').length ||
1007
                (that.saved && !saveSuccess)
1008
            ) {
1009
                that.saved = false;
3✔
1010
            }
1011

1012
            // when the window has been changed pressing the blue or red button, we need to run a reload check
1013
            // also check that no delete-confirmation is required
1014
            if (that.saved && saveSuccess && !contents.find('.delete-confirmation').length) {
25✔
1015
                that.ui.modalBody.addClass('cms-loader');
1✔
1016
                if (that.options.onClose) {
1!
UNCOV
1017
                    showLoader();
×
UNCOV
1018
                    Helpers.reloadBrowser(
×
1019
                        that.options.onClose ? that.options.onClose : window.location.href,
×
1020
                        false,
1021
                        true
1022
                    );
1023
                } else {
1024
                    // hello ckeditor
1025
                    Helpers.removeEventListener('modal-close.text-plugin');
1✔
1026
                    that.close();
1✔
1027
                    // Serve the data bridge:
1028
                    // We have a special case here cause the CMS namespace
1029
                    // can be either inside the current window or the parent
1030
                    const dataBridge = body[0].querySelector('script#data-bridge');
1✔
1031

1032
                    if (dataBridge) {
1!
1033
                        // the dataBridge is used to access plugin information from different resources
1034
                        // Do NOT move this!!!
UNCOV
1035
                        try {
×
UNCOV
1036
                            CMS.API.Helpers.dataBridge = JSON.parse(dataBridge.textContent);
×
UNCOV
1037
                            CMS.API.Helpers.onPluginSave();
×
1038
                        } catch {
1039
                            // istanbul ignore next
1040
                            Helpers.reloadBrowser();
1041
                        }
1042
                    }
1043
                }
1044
            } else {
1045
                if (that.ui.modal.hasClass('cms-modal-open')) {
24!
1046
                    // show modal if hidden
UNCOV
1047
                    that.ui.modal.show();
×
1048
                }
1049
                iframe.show();
24✔
1050
                // set title of not provided
1051
                innerTitle = contents.find('#content h1:eq(0)');
24✔
1052

1053
                // case when there is no prefix
1054
                // istanbul ignore next: never happens
1055
                if (opts.title === undefined && that.ui.titlePrefix.text() === '') {
1056
                    bc = contents.find('.breadcrumbs').contents();
1057
                    that.ui.titlePrefix.text(bc.eq(bc.length - 1).text().replace('›', '').trim());
1058
                }
1059

1060
                if (titlePrefix.text().trim() === '') {
24✔
1061
                    titlePrefix.text(innerTitle.text());
23✔
1062
                } else {
1063
                    titleSuffix.text(innerTitle.text());
1✔
1064
                }
1065
                innerTitle.remove();
24✔
1066

1067
                // than show
1068
                iframe.css('visibility', 'visible');
24✔
1069

1070
                // append ready state
1071
                iframe.data('ready', true);
24✔
1072

1073
                // attach close event
1074
                body.on('keydown.cms', function(e) {
24✔
1075
                    if (e.keyCode === KEYS.ESC && that.options.closeOnEsc) {
3✔
1076
                        e.stopPropagation();
2✔
1077
                        if (that._confirmDirtyEscCancel()) {
2✔
1078
                            that._cancelHandler();
1✔
1079
                        }
1080
                    }
1081
                });
1082

1083
                // figure out if .object-tools is available
1084
                if (contents.find('.object-tools').length) {
24✔
1085
                    contents.find('#content').css('padding-top', 38); // eslint-disable-line
8✔
1086
                }
1087

1088
                // this is required for IE11. we assume that when the modal is opened the user is going to interact
1089
                // with it. if we don't focus the body directly the next time the user clicks on a field inside
1090
                // the iframe the focus will be stolen by body thus requiring two clicks. this immediately focuses the
1091
                // iframe body on load except if something is already focused there
1092
                // (django tries to focus first field by default)
1093
                setTimeout(() => {
24✔
1094
                    if (!iframe[0] || !iframe[0].contentDocument || !iframe[0].contentDocument.documentElement) {
23✔
1095
                        return;
22✔
1096
                    }
1097
                    if ($(iframe[0].contentDocument.documentElement).find(':focus').length) {
1!
UNCOV
1098
                        return;
×
1099
                    }
1100
                    iframe.trigger('focus');
1✔
1101
                }, 0);
1102
            }
1103

1104
            that._attachContentPreservingHandlers(iframe);
25✔
1105
        });
1106

1107
        // inject
1108
        holder.html(iframe);
30✔
1109
    }
1110

1111
    /**
1112
     * Adds handlers to prevent accidental refresh / modal close
1113
     * that could lead to loss of data.
1114
     *
1115
     * @method _attachContentPreservingHandlers
1116
     * @private
1117
     * @param {jQuery} iframe
1118
     */
1119
    _attachContentPreservingHandlers(iframe) {
1120
        var that = this;
26✔
1121

1122
        that.tracker = new ChangeTracker(iframe);
26✔
1123

1124
        Helpers._getWindow().addEventListener('beforeunload', this._beforeUnloadHandler);
26✔
1125
    }
1126

1127
    /**
1128
     * @method _beforeUnloadHandler
1129
     * @private
1130
     * @param {Event} e
1131
     * @returns {String|void}
1132
     */
1133
    _beforeUnloadHandler(e) {
1134
        if (this.tracker.isFormChanged()) {
3✔
1135
            e.returnValue = CMS.config.lang.confirmDirty;
2✔
1136
            return e.returnValue;
2✔
1137
        }
1138
    }
1139

1140
    /**
1141
     * Similar functionality as in `_attachContentPreservingHandlers` but for canceling
1142
     * the modal with the ESC button.
1143
     *
1144
     * @method _confirmDirtyEscCancel
1145
     * @private
1146
     * @returns {Boolean}
1147
     */
1148
    _confirmDirtyEscCancel() {
1149
        if (this.tracker && this.tracker.isFormChanged()) {
6✔
1150
            return Helpers.secureConfirm(CMS.config.lang.confirmDirty + '\n\n' + CMS.config.lang.confirmDirtyESC);
2✔
1151
        }
1152
        return true;
4✔
1153
    }
1154

1155
    /**
1156
     * Version where the modal loads an url within an iframe.
1157
     *
1158
     * @method _changeIframe
1159
     * @private
1160
     * @param {jQuery} el originated element
1161
     * @returns {Boolean|void}
1162
     */
1163
    _changeIframe(el) {
1164
        if (el.hasClass('active')) {
4✔
1165
            return false;
1✔
1166
        }
1167

1168
        var parents = el.parent().find('a');
3✔
1169

1170
        parents.removeClass('active');
3✔
1171
        el.addClass('active');
3✔
1172
        this._loadIframe({
3✔
1173
            url: el.attr('href')
1174
        });
1175
        this.ui.titlePrefix.text(el.text());
3✔
1176
    }
1177

1178
    /**
1179
     * Version where the modal loads html markup.
1180
     *
1181
     * @method _loadMarkup
1182
     * @private
1183
     * @param {Object} opts
1184
     * @param {String|HTMLNode|jQuery} opts.html html markup to render
1185
     * @param {String} opts.title modal window main title (bold)
1186
     * @param {String} [opts.subtitle] modal window secondary title (normal)
1187
     */
1188
    _loadMarkup(opts) {
1189
        this.ui.modal.removeClass('cms-modal-iframe');
30✔
1190
        this.ui.modal.addClass('cms-modal-markup');
30✔
1191
        this.ui.modalBody.removeClass('cms-loader');
30✔
1192

1193
        // set content
1194
        // empty to remove events, append to keep events
1195
        this.ui.frame.empty().append(opts.html);
30✔
1196
        this.ui.titlePrefix.text(opts.title || '');
30✔
1197
        this.ui.titleSuffix.text(opts.subtitle || '');
30✔
1198
        trap(this.ui.frame[0]);
30✔
1199
    }
1200

1201
    /**
1202
     * Called whenever default modal action is canceled.
1203
     *
1204
     * @method _cancelHandler
1205
     * @private
1206
     */
1207
    _cancelHandler() {
1208
        this.options.onClose = null;
8✔
1209
        this.close();
8✔
1210
    }
1211

1212
    /**
1213
     * Sets up keyup/keydown listeners so you're able to save whatever you're
1214
     * editing inside of an iframe by pressing `ctrl + enter` on windows and `cmd + enter` on mac.
1215
     *
1216
     * It only works with default button (e.g. action), not the `delete` button,
1217
     * even though sometimes it's the only actionable button in the modal.
1218
     *
1219
     * @method _setupCtrlEnterSave
1220
     * @private
1221
     * @static
1222
     * @param {HTMLElement} doc document element (iframe or parent window);
1223
     */
1224
    static _setupCtrlEnterSave(doc) {
1225
        var cmdPressed = false;
6✔
1226
        var mac = navigator.platform.toLowerCase().indexOf('mac') + 1;
6✔
1227

1228
        $(doc)
6✔
1229
            .on('keydown.cms.submit', function(e) {
1230
                if (e.ctrlKey && e.keyCode === KEYS.ENTER && !mac) {
22✔
1231
                    $('.cms-modal-buttons .cms-btn-action:first').trigger('click');
1✔
1232
                }
1233

1234
                if (mac) {
22✔
1235
                    if (e.keyCode === KEYS.CMD_LEFT || e.keyCode === KEYS.CMD_RIGHT || e.keyCode === KEYS.CMD_FIREFOX) {
14✔
1236
                        cmdPressed = true;
6✔
1237
                    }
1238

1239
                    if (e.keyCode === KEYS.ENTER && cmdPressed) {
14✔
1240
                        $('.cms-modal-buttons .cms-btn-action:first').trigger('click');
3✔
1241
                    }
1242
                }
1243
            })
1244
            .on('keyup.cms.submit', function(e) {
1245
                if (mac) {
22✔
1246
                    if (e.keyCode === KEYS.CMD_LEFT || e.keyCode === KEYS.CMD_RIGHT || e.keyCode === KEYS.CMD_FIREFOX) {
14✔
1247
                        cmdPressed = false;
6✔
1248
                    }
1249
                }
1250
            });
1251
    }
1252
}
1253

1254
Modal.options = {
1✔
1255
    onClose: false,
1256
    closeOnEsc: true,
1257
    minHeight: 400,
1258
    minWidth: 800,
1259
    modalDuration: 200,
1260
    resizable: true,
1261
    maximizable: true,
1262
    minimizable: true
1263
};
1264

1265
export default Modal;
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