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

divio / django-cms / #30102

12 Nov 2025 11:07AM UTC coverage: 90.532%. Remained the same
#30102

push

travis-ci

web-flow
Merge 12b8c6dbd into c38b75715

1306 of 2044 branches covered (63.89%)

294 of 321 new or added lines in 13 files covered. (91.59%)

458 existing lines in 7 files now uncovered.

9151 of 10108 relevant lines covered (90.53%)

11.16 hits per line

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

87.68
/cms/static/cms/js/modules/cms.base.js
1
/**
2
 * CMS.API.Helpers
3
 * Multiple helpers used across all CMS features
4
 */
5
import $ from 'jquery';
6
// switched from commonjs 'lodash' bundle to per-method ESM imports for better tree-shaking
7
import once from 'lodash-es/once.js';
8
import debounce from 'lodash-es/debounce.js';
9
import throttle from 'lodash-es/throttle.js';
10
import { showLoader, hideLoader } from './loader';
11

12
var _CMS = {
1✔
13
    API: {}
14
};
15

16
/**
17
 * @function _ns
18
 * @private
19
 * @param {String} events space separated event names to be namespaces
20
 * @returns {String} string containing space separated namespaced event names
21
 */
22
var _ns = function nameSpaceEvent(events) {
1✔
23
    return events
281✔
24
        .split(/\s+/g)
25
        .map(function(eventName) {
26
            return 'cms-' + eventName;
396✔
27
        })
28
        .join(' ');
29
};
30

31
// Handy shortcut to cache the window and the document objects
32
// in a jquery wrapper
33
export const $window = $(window);
1✔
34
export const $document = $(document);
1✔
35

36
/**
37
 * Creates always an unique identifier if called
38
 * @returns {Number} incremental numbers starting from 0
39
 */
40
export const uid = (function() {
1✔
41
    let i = 0;
1✔
42

43
    return () => ++i;
182✔
44
})();
45

46
/**
47
 * Checks if the current version of the CMS matches provided one
48
 *
49
 * @param {Object} settings
50
 * @param {String} settings.version CMS version
51
 * @returns {Boolean}
52
 */
53
export const currentVersionMatches = ({ version }) => {
1✔
54
    return version === __CMS_VERSION__;
6✔
55
};
56

57
/**
58
 * Provides various helpers that are mixed in all CMS classes.
59
 *
60
 * @class Helpers
61
 * @static
62
 * @module CMS
63
 * @submodule CMS.API
64
 * @namespace CMS.API
65
 */
66
export const Helpers = {
1✔
67
    /**
68
     * See {@link reloadBrowser}
69
     *
70
     * @property {Boolean} isRloading
71
     * @private
72
     */
73
    _isReloading: false,
74

75
    // aliasing the $window and the $document objects
76
    $window,
77
    $document,
78

79
    uid,
80

81
    once,
82
    debounce,
83
    throttle,
84

85
    /**
86
     * Redirects to a specific url or reloads browser.
87
     *
88
     * @method reloadBrowser
89
     * @param {String} url where to redirect. if equal to `REFRESH_PAGE` will reload page instead
90
     * @param {Number} timeout=0 timeout in ms
91
     * @returns {void}
92
     */
93

94
    reloadBrowser: function(url, timeout) {
95
        var that = this;
3✔
96
        // is there a parent window?
97
        var win = this._getWindow();
3✔
98
        var parent = win.parent ? win.parent : win;
3!
99

100
        that._isReloading = true;
3✔
101

102
        // add timeout if provided
103
        parent.setTimeout(function() {
3✔
104
            if (url === 'REFRESH_PAGE' || !url || url === parent.location.href) {
3✔
105
                // ensure page is always reloaded #3413
106
                parent.location.reload();
1✔
107
            } else {
108
                // location.reload() takes precedence over this, so we
109
                // don't want to reload the page if we need a redirect
110
                parent.location.href = url;
2✔
111
            }
112
        }, timeout || 0);
5✔
113
    },
114

115
    /**
116
     * Overridable callback that is being called in close_frame.html when plugin is saved
117
     *
118
     * @function onPluginSave
119
     * @public
120
     */
121
    onPluginSave: function() {
122
        const data = this.dataBridge || {};
4✔
123
        const action = data.action ? data.action.toUpperCase() : null;
4✔
124

125
        switch (action) {
4✔
126
            case 'CHANGE':
127
            case 'EDIT':
128
                if (this._pluginExists(data.plugin_id)) {
2✔
129
                    CMS.API.StructureBoard.invalidateState('EDIT', data);
1✔
130
                } else {
131
                    CMS.API.StructureBoard.invalidateState('ADD', data);
1✔
132
                }
133
                return;
2✔
134
            case 'ADD':
135
            case 'DELETE':
136
            case 'CLEAR_PLACEHOLDER':
137
                CMS.API.StructureBoard.invalidateState(action, data);
1✔
138
                return;
1✔
139
            default:
140
                break;
1✔
141
        }
142

143
        // istanbul ignore else
144
        if (!this._isReloading) {
1✔
145
            this.reloadBrowser(null, 300); // eslint-disable-line
1✔
146
        }
147
    },
148

149
    /*
150
     * Check if a plugin object existst for the given plugin id
151
     *
152
     * @method _pluginExists
153
     * @private
154
     * @param {String} pluginId
155
     * @returns {Boolean}
156
     */
157
    _pluginExists: function(pluginId) {
158
        return window.CMS._instances.some(function(plugin) {
2✔
159
            return Number(plugin.options.plugin_id) === Number(pluginId) && plugin.options.type === 'plugin';
2✔
160
        });
161
    },
162

163
    /**
164
     * Assigns an event handler to forms located in the toolbar
165
     * to prevent multiple submissions.
166
     *
167
     * @method preventSubmit
168
     */
169
    preventSubmit: function() {
170
        var forms = $('.cms-toolbar').find('form');
2✔
171
        var SUBMITTED_OPACITY = 0.5;
2✔
172

173
        forms.submit(function() {
2✔
174
            // show loader
175
            showLoader();
1✔
176
            // we cannot use disabled as the name action will be ignored
177
            $('input[type="submit"]')
1✔
178
                .on('click', function(e) {
179
                    e.preventDefault();
2✔
180
                })
181
                .css('opacity', SUBMITTED_OPACITY);
182
        });
183
    },
184

185
    /**
186
     * Sets csrf token header on ajax requests.
187
     *
188
     * @method csrf
189
     * @param {String} csrf_token
190
     */
191
    csrf: function(csrf_token) {
192
        $.ajaxSetup({
22✔
193
            beforeSend: function(xhr) {
194
                xhr.setRequestHeader('X-CSRFToken', csrf_token);
34✔
195
            }
196
        });
197
    },
198

199
    /**
200
     * Sends or retrieves a JSON from localStorage
201
     * or the session (through synchronous ajax request)
202
     * if localStorage is not available. Does not merge with
203
     * previous setSettings calls.
204
     *
205
     * @method setSettings
206
     * @param {Object} newSettings
207
     * @returns {Object}
208
     */
209
    setSettings: function(newSettings) {
210
        // merge settings
211
        var settings = JSON.stringify($.extend({}, window.CMS.config.settings, newSettings));
119✔
212

213
        // use local storage or session
214
        if (this._isStorageSupported) {
119✔
215
            // save within local storage
216
            localStorage.setItem('cms_cookie', settings);
114✔
217
        } else {
218
            // save within session
219
            CMS.API.locked = true;
5✔
220
            showLoader();
5✔
221

222
            $.ajax({
5✔
223
                async: false,
224
                type: 'POST',
225
                url: window.CMS.config.urls.settings,
226
                data: {
227
                    csrfmiddlewaretoken: window.CMS.config.csrf,
228
                    settings: settings
229
                },
230
                success: function(data) {
231
                    CMS.API.locked = false;
3✔
232
                    // determine if logged in or not
233
                    settings = data ? JSON.parse(data) : window.CMS.config.settings;
3✔
234
                    hideLoader();
3✔
235
                },
236
                error: function(jqXHR) {
237
                    CMS.API.Messages.open({
2✔
238
                        message: jqXHR.responseText + ' | ' + jqXHR.status + ' ' + jqXHR.statusText,
239
                        error: true
240
                    });
241
                }
242
            });
243
        }
244

245
        // save settings
246
        CMS.settings = typeof settings === 'object' ? settings : JSON.parse(settings);
119✔
247

248
        // ensure new settings are returned
249
        return CMS.settings;
119✔
250
    },
251

252
    /**
253
     * Gets user settings (from localStorage or the session)
254
     * in the same way as setSettings sets them.
255
     *
256
     * @method getSettings
257
     * @returns {Object}
258
     */
259
    getSettings: function() {
260
        var settings;
261

262

263
        // use local storage or session
264
        if (this._isStorageSupported) {
9✔
265
            // get from local storage
266
            settings = JSON.parse(localStorage.getItem('cms_cookie') || 'null');
6✔
267
        } else {
268
            showLoader();
3✔
269
            CMS.API.locked = true;
3✔
270
            // get from session
271
            $.ajax({
3✔
272
                async: false,
273
                type: 'GET',
274
                url: window.CMS.config.urls.settings,
275
                success: function(data) {
276
                    CMS.API.locked = false;
2✔
277
                    // determine if logged in or not
278
                    settings = data ? JSON.parse(data) : window.CMS.config.settings;
2✔
279
                    hideLoader();
2✔
280
                },
281
                error: function(jqXHR) {
282
                    CMS.API.Messages.open({
1✔
283
                        message: jqXHR.responseText + ' | ' + jqXHR.status + ' ' + jqXHR.statusText,
284
                        error: true
285
                    });
286
                }
287
            });
288
        }
289

290
        // edit_off is a random flag that should be available on the page, but sometimes can
291
        // be not set when settings are carried over from pagetree
292
        if (
9✔
293
            (!settings || !currentVersionMatches(settings))
15✔
294
        ) {
295
            settings = this.setSettings(window.CMS.config.settings);
7✔
296
        }
297

298
        // save settings
299
        CMS.settings = settings;
9✔
300

301
        // ensure new settings are returned
302
        return CMS.settings;
9✔
303
    },
304

305
    /**
306
     * Modifies the url with new params and sanitises the url
307
     * reversing any & to ampersand (introduced with #3404)
308
     *
309
     * @method makeURL
310
     * @param {String} url original url
311
     * @param {Array[]} [params] array of [`param`, `value`] arrays to update the url
312
     * @returns {String}
313
     */
314
    makeURL: function makeURL(url, params) {
315
        const urlParams = params || [];
103✔
316
        // Decode URL and replace & with &
317
        const decodedUrl = decodeURIComponent(url.replace(/&/g, '&'));
103✔
318
        let newUrl;
319
        let isAbsolute = false;
103✔
320
        let hadLeadingSlash = decodedUrl.startsWith('/');
103✔
321

322
        try {
103✔
323
            // Try to parse as absolute URL
324
            // eslint-disable-next-line no-undef
325
            newUrl = new URL(decodedUrl);
103✔
326
            isAbsolute = true;
3✔
327
        } catch {
328
            // If relative, use window.location.origin as base
329
            // eslint-disable-next-line no-undef
330
            newUrl = new URL(decodedUrl, window.location.origin);
100✔
331
        }
332

333
        urlParams.forEach(function(pair) {
103✔
334
            var key = pair[0];
55✔
335
            var value = pair[1];
55✔
336

337
            newUrl.searchParams.delete(key);
55✔
338
            newUrl.searchParams.set(key, value);
55✔
339
        });
340

341
        // Return full URL if input was absolute, otherwise return relative path
342
        if (isAbsolute) {
103✔
343
            return newUrl.toString();
3✔
344
        }
345

346
        let result = newUrl.pathname + newUrl.search + newUrl.hash;
100✔
347
        // Remove leading slash if original URL didn't have one
348

349
        if (!hadLeadingSlash && result.startsWith('/')) {
100✔
350
            result = result.substring(1);
22✔
351
        }
352
        return result;
100✔
353
    },
354

355
    /**
356
     * Browsers allow to "Prevent this page form creating additional
357
     * dialogs." checkbox which prevents further input from confirm messages.
358
     * This method falls back to "true" once the user chooses this option.
359
     *
360
     * @method secureConfirm
361
     * @param {String} message to be displayed
362
     * @returns {Boolean}
363
     */
364
    secureConfirm: function secureConfirm(message) {
365
        var start = Number(new Date());
3✔
366
        var result = confirm(message); // eslint-disable-line
3✔
367
        var end = Number(new Date());
3✔
368
        var MINIMUM_DELAY = 10;
3✔
369

370
        return end < start + MINIMUM_DELAY || result === true;
3✔
371
    },
372

373
    /**
374
     * Is localStorage truly supported?
375
     * Check is taken from modernizr.
376
     *
377
     * @property _isStorageSupported
378
     * @private
379
     * @type {Boolean}
380
     */
381
    _isStorageSupported: (function localStorageCheck() {
382
        var mod = 'modernizr';
1✔
383

384
        try {
1✔
385
            localStorage.setItem(mod, mod);
1✔
386
            localStorage.removeItem(mod);
1✔
387
            return true;
1✔
388
        } catch {
389
            // istanbul ignore next
390
            return false;
391
        }
392
    })(),
393

394
    /**
395
     * Adds an event listener to the "CMS".
396
     *
397
     * @method addEventListener
398
     * @param {String} eventName string containing space separated event names
399
     * @param {Function} fn callback to run when the event happens
400
     * @returns {jQuery}
401
     */
402
    addEventListener: function addEventListener(eventName, fn) {
403
        return CMS._eventRoot && CMS._eventRoot.on(_ns(eventName), fn);
102✔
404
    },
405

406
    /**
407
     * Removes the event listener from the "CMS". If a callback is provided - removes only that callback.
408
     *
409
     * @method removeEventListener
410
     * @param {String} eventName string containing space separated event names
411
     * @param {Function} [fn] specific callback to be removed
412
     * @returns {jQuery}
413
     */
414
    removeEventListener: function removeEventListener(eventName, fn) {
415
        return CMS._eventRoot && CMS._eventRoot.off(_ns(eventName), fn);
44✔
416
    },
417

418
    /**
419
     * Dispatches an event
420
     * @method dispatchEvent
421
     * @param {String} eventName event name
422
     * @param {Object} payload whatever payload required for the consumer
423
     * @returns {$.Event} event that was just triggered
424
     */
425
    dispatchEvent: function dispatchEvent(eventName, payload) {
426
        var event = new $.Event(_ns(eventName));
139✔
427

428
        CMS._eventRoot.trigger(event, [payload]);
139✔
429
        return event;
139✔
430
    },
431

432
    /**
433
     * Prevents scrolling with touch in an element.
434
     *
435
     * @method preventTouchScrolling
436
     * @param {jQuery} element element where we are preventing the scroll
437
     * @param {String} namespace so we don't mix events from two different places on the same element
438
     */
439
    preventTouchScrolling: function preventTouchScrolling(element, namespace) {
440
        element.on('touchmove.cms.preventscroll.' + namespace, function(e) {
21✔
441
            e.preventDefault();
1✔
442
        });
443
    },
444

445
    /**
446
     * Allows scrolling with touch in an element.
447
     *
448
     * @method allowTouchScrolling
449
     * @param {jQuery} element element where we are allowing the scroll again
450
     * @param {String} namespace so we don't accidentally remove events from a different handler
451
     */
452
    allowTouchScrolling: function allowTouchScrolling(element, namespace) {
453
        element.off('touchmove.cms.preventscroll.' + namespace);
6✔
454
    },
455

456
    /**
457
     * Returns window object.
458
     *
459
     * @method _getWindow
460
     * @private
461
     * @returns {Window}
462
     */
463
    _getWindow: function() {
464
        return window;
219✔
465
    },
466

467
    /**
468
     * We need to update the url with cms_path param for undo/redo
469
     *
470
     * @function updateUrlWithPath
471
     * @private
472
     * @param {String} url url
473
     * @returns {String} modified url
474
     */
475
    updateUrlWithPath: function(url) {
476
        var win = this._getWindow();
37✔
477
        var path = win.location.pathname + win.location.search;
37✔
478

479
        return this.makeURL(url, [['cms_path', path]]);
37✔
480
    },
481

482
    /**
483
     * Get color scheme either from :root[data-theme] or user system setting
484
     *
485
     * @method get_color_scheme
486
     * @public
487
     * @returns {String}
488
     */
489
    getColorScheme: function () {
490
        let state = $('html').attr('data-theme');
3✔
491

492
        if (!state) {
3!
UNCOV
493
            state = localStorage.getItem('theme') || CMS.config.color_scheme || 'auto';
×
494
        }
495
        return state;
3✔
496
    },
497

498
    /**
499
     * Sets the color scheme for the current document and all iframes contained.
500
     *
501
     * @method setColorScheme
502
     * @public
503
     * @param scheme {String}
504
     * @returns {void}
505
     */
506

507
    setColorScheme: function (mode) {
508
        let body = $('html');
4✔
509
        let scheme = (mode !== 'light' && mode !== 'dark') ? 'auto' : mode;
4✔
510

511
        if (localStorage.getItem('theme') || CMS.config.color_scheme !== scheme) {
4!
512
            // Only set local storage if it is either already set or if scheme differs from preset
513
            // to avoid fixing the user setting to the preset (which would ignore a change in presets)
514
            localStorage.setItem('theme', scheme);
4✔
515
        }
516

517
        body.attr('data-theme', scheme);
4✔
518
        body.find('div.cms iframe').each(function setFrameColorScheme(i, e) {
4✔
UNCOV
519
            if (e.contentDocument) {
×
UNCOV
520
                e.contentDocument.documentElement.dataset.theme = scheme;
×
521
                // ckeditor (and potentially other apps) have iframes inside their admin forms
522
                // also set color scheme there
UNCOV
523
                $(e.contentDocument).find('iframe').each(setFrameColorScheme);
×
524
            }
525
        });
526
    },
527

528
    /**
529
     * Cycles the color scheme for the current document and all iframes contained.
530
     * Follows the logic introduced in Django's 4.2 admin
531
     *
532
     * @method setColorScheme
533
     * @public}
534
     * @returns {void}
535
     */
536
    toggleColorScheme: function () {
UNCOV
537
        const currentTheme = this.getColorScheme();
×
UNCOV
538
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
×
539

UNCOV
540
        if (prefersDark) {
×
541
            // Auto (dark) -> Light -> Dark
UNCOV
542
            if (currentTheme === 'auto') {
×
UNCOV
543
                this.setColorScheme('light');
×
UNCOV
544
            } else if (currentTheme === 'light') {
×
UNCOV
545
                this.setColorScheme('dark');
×
546
            } else {
UNCOV
547
                this.setColorScheme('auto');
×
548
            }
549
        } else {
550
            // Auto (light) -> Dark -> Light
551
            // eslint-disable-next-line no-lonely-if
552
            if (currentTheme === 'auto') {
×
UNCOV
553
                this.setColorScheme('dark');
×
UNCOV
554
            } else if (currentTheme === 'dark') {
×
UNCOV
555
                this.setColorScheme('light');
×
556
            } else {
UNCOV
557
                this.setColorScheme('auto');
×
558
            }
559
        }
560
    }
561
};
562

563

564
/**
565
 * Provides key codes for common keys.
566
 *
567
 * @module CMS
568
 * @submodule CMS.KEYS
569
 * @example
570
 *     if (e.keyCode === CMS.KEYS.ENTER) { ... };
571
 */
572
export const KEYS = {
1✔
573
    SHIFT: 16,
574
    TAB: 9,
575
    UP: 38,
576
    DOWN: 40,
577
    ENTER: 13,
578
    SPACE: 32,
579
    ESC: 27,
580
    CMD_LEFT: 91,
581
    CMD_RIGHT: 93,
582
    CMD_FIREFOX: 224,
583
    CTRL: 17
584
};
585

586
// shorthand for jQuery(document).ready();
587
$(function() {
1✔
588
    CMS._eventRoot = $('#cms-top');
1✔
589
    // autoinits
590
    Helpers.preventSubmit();
1✔
591
});
592

593
export default _CMS;
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