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

divio / django-cms / #30638

10 May 2026 02:13PM UTC coverage: 28.238% (+5.8%) from 22.451%
#30638

push

travis-ci

316 of 1361 branches covered (23.22%)

774 of 2741 relevant lines covered (28.24%)

12.49 hits per line

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

34.4
/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';
1✔
11

12
var _CMS = {
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
1✔
21
 */
×
22
var _ns = function nameSpaceEvent(events) {
23
    return events
24
        .split(/\s+/g)
×
25
        .map(function(eventName) {
26
            return 'cms-' + eventName;
27
        })
28
        .join(' ');
29
};
30

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

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

43
    return () => ++i;
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}
1✔
52
 */
1✔
53
export const currentVersionMatches = ({ version }) => {
54
    return version === __CMS_VERSION__;
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
1✔
65
 */
66
export const Helpers = {
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;
×
96
        // is there a parent window?
×
97
        var win = this._getWindow();
98
        var parent = win.parent ? win.parent : win;
×
99

100
        that._isReloading = true;
101

×
102
        // add timeout if provided
×
103
        parent.setTimeout(function() {
104
            if (url === 'REFRESH_PAGE' || !url || url === parent.location.href) {
×
105
                // ensure page is always reloaded #3413
106
                parent.location.reload();
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;
×
111
            }
112
        }, timeout || 0);
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 || {};
123
        const action = data.action ? data.action.toUpperCase() : null;
×
124

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

×
143
        // istanbul ignore else
×
144
        if (!this._isReloading) {
145
            this.reloadBrowser(null, 300); // eslint-disable-line
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) {
159
            return Number(plugin.options.plugin_id) === Number(pluginId) && plugin.options.type === 'plugin';
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
     */
1✔
169
    preventSubmit: function() {
1✔
170
        var forms = $('.cms-toolbar').find('form');
171
        var SUBMITTED_OPACITY = 0.5;
1✔
172

173
        forms.submit(function() {
×
174
            // show loader
175
            showLoader();
×
176
            // we cannot use disabled as the name action will be ignored
177
            $('input[type="submit"]')
×
178
                .on('click', function(e) {
179
                    e.preventDefault();
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({
×
193
            beforeSend: function(xhr) {
194
                xhr.setRequestHeader('X-CSRFToken', csrf_token);
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) {
98✔
210
        // merge settings
211
        var settings = JSON.stringify($.extend({}, window.CMS.config.settings, newSettings));
212

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

222
            $.ajax({
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;
×
232
                    // determine if logged in or not
×
233
                    settings = data ? JSON.parse(data) : window.CMS.config.settings;
234
                    hideLoader();
235
                },
×
236
                error: function(jqXHR) {
237
                    CMS.API.Messages.open({
238
                        message: jqXHR.responseText + ' | ' + jqXHR.status + ' ' + jqXHR.statusText,
239
                        error: true
240
                    });
241
                }
242
            });
243
        }
244

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

98✔
248
        // ensure new settings are returned
249
        return CMS.settings;
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

1!
263
        // use local storage or session
264
        if (this._isStorageSupported) {
1!
265
            // get from local storage
266
            settings = JSON.parse(localStorage.getItem('cms_cookie') || 'null');
×
267
        } else {
×
268
            showLoader();
269
            CMS.API.locked = true;
×
270
            // get from session
271
            $.ajax({
272
                async: false,
273
                type: 'GET',
274
                url: window.CMS.config.urls.settings,
×
275
                success: function(data) {
276
                    CMS.API.locked = false;
×
277
                    // determine if logged in or not
×
278
                    settings = data ? JSON.parse(data) : window.CMS.config.settings;
279
                    hideLoader();
280
                },
×
281
                error: function(jqXHR) {
282
                    CMS.API.Messages.open({
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
1!
291
        // be not set when settings are carried over from pagetree
2✔
292
        if (
293
            (!settings || !currentVersionMatches(settings))
1✔
294
        ) {
295
            settings = this.setSettings(window.CMS.config.settings);
296
        }
297

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

1✔
301
        // ensure new settings are returned
302
        return CMS.settings;
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
     */
1✔
314
    makeURL: function makeURL(url, params) {
315
        const urlParams = params || [];
1✔
316
        // Decode URL and replace & with &
1✔
317
        const decodedUrl = decodeURIComponent(url.replace(/&/g, '&'));
318
        let newUrl;
1✔
319
        let isAbsolute = false;
1✔
320
        let hadLeadingSlash = decodedUrl.startsWith('/');
321

322
        try {
1✔
323
            // Try to parse as absolute URL
324
            newUrl = new URL(decodedUrl);
325
            isAbsolute = true;
326
        } catch {
327
            // If relative, use window.location.origin as base
328
            newUrl = new URL(decodedUrl, window.location.origin);
329
        }
330

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

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

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

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

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

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

368
        return end < start + MINIMUM_DELAY || result === true;
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';
381

382
        try {
383
            localStorage.setItem(mod, mod);
384
            localStorage.removeItem(mod);
385
            return true;
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);
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);
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));
×
425

426
        CMS._eventRoot.trigger(event, [payload]);
427
        return event;
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
134✔
436
     */
437
    preventTouchScrolling: function preventTouchScrolling(element, namespace) {
438
        element.on('touchmove.cms.preventscroll.' + namespace, function(e) {
439
            e.preventDefault();
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
1✔
448
     * @param {String} namespace so we don't accidentally remove events from a different handler
1✔
449
     */
450
    allowTouchScrolling: function allowTouchScrolling(element, namespace) {
1✔
451
        element.off('touchmove.cms.preventscroll.' + namespace);
452
    },
453

454
    /**
455
     * Returns window object.
456
     *
457
     * @method _getWindow
458
     * @private
459
     * @returns {Window}
460
     */
461
    _getWindow: function() {
×
462
        return window;
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();
475
        var path = win.location.pathname + win.location.search;
476

477
        return this.makeURL(url, [['cms_path', path]]);
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');
×
489

×
490
        if (!state) {
×
491
            state = localStorage.getItem('theme') || CMS.config.color_scheme || 'auto';
×
492
        }
493
        return state;
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');
507
        let scheme = (mode !== 'light' && mode !== 'dark') ? 'auto' : mode;
508

×
509
        if (localStorage.getItem('theme') || CMS.config.color_scheme !== scheme) {
×
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);
513
        }
×
514

×
515
        body.attr('data-theme', scheme);
×
516
        body.find('div.cms iframe').each(function setFrameColorScheme(i, e) {
×
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');
1✔
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
    }
1✔
559
};
1✔
560

561

1✔
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 = {
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;
586
_CMS.KEYS = KEYS;
587

588
// shorthand for jQuery(document).ready();
589
$(function() {
590
    CMS._eventRoot = $('#cms-top');
591
    // autoinits
592
    Helpers.preventSubmit();
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