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

divio / django-cms / #30625

07 May 2026 01:13PM UTC coverage: 90.235% (+14.3%) from 75.915%
#30625

push

travis-ci

web-flow
Merge 13c5f046e into 155395fcc

1389 of 2213 branches covered (62.77%)

9546 of 10579 relevant lines covered (90.24%)

11.18 hits per line

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

87.86
/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
            newUrl = new URL(decodedUrl);
103✔
325
            isAbsolute = true;
3✔
326
        } catch {
327
            // If relative, use window.location.origin as base
328
            newUrl = new URL(decodedUrl, window.location.origin);
100✔
329
        }
330

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

561

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

584
// Add Helpers and KEYS to _CMS for backwards compatibility with tests
585
_CMS.API.Helpers = Helpers;
1✔
586
_CMS.KEYS = KEYS;
1✔
587

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

595
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

© 2026 Coveralls, Inc