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

divio / django-cms / #29891

26 Aug 2025 01:24AM UTC coverage: 75.132% (+0.07%) from 75.059%
#29891

push

travis-ci

web-flow
Merge 09573ad01 into de255061d

1079 of 1626 branches covered (66.36%)

2568 of 3418 relevant lines covered (75.13%)

26.22 hits per line

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

86.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
import URL from 'urijs';
7
import { once, debounce, throttle } from 'lodash';
8
import { showLoader, hideLoader } from './loader';
9

10
var _CMS = {
1✔
11
    API: {}
12
};
13

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

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

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

41
    return () => ++i;
182✔
42
})();
43

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

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

73
    // aliasing the $window and the $document objects
74
    $window,
75
    $document,
76

77
    uid,
78

79
    once,
80
    debounce,
81
    throttle,
82

83
    /**
84
     * Redirects to a specific url or reloads browser.
85
     *
86
     * @method reloadBrowser
87
     * @param {String} url where to redirect. if equal to `REFRESH_PAGE` will reload page instead
88
     * @param {Number} timeout=0 timeout in ms
89
     * @returns {void}
90
     */
91
    // eslint-disable-next-line max-params
92
    reloadBrowser: function(url, timeout) {
93
        var that = this;
3✔
94
        // is there a parent window?
95
        var win = this._getWindow();
3✔
96
        var parent = win.parent ? win.parent : win;
3!
97

98
        that._isReloading = true;
3✔
99

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

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

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

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

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

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

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

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

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

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

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

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

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

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

260

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

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

296
        // save settings
297
        CMS.settings = settings;
9✔
298

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

303
    /**
304
     * Modifies the url with new params and sanitises the url
305
     * reversing any & to ampersand (introduced with #3404)
306
     *
307
     * @method makeURL
308
     * @param {String} url original url
309
     * @param {Array[]} [params] array of [`param`, `value`] arrays to update the url
310
     * @returns {String}
311
     */
312
    makeURL: function makeURL(url, params = []) {
53✔
313
        let newUrl = new URL(URL.decode(url.replace(/&/g, '&')));
103✔
314

315
        params.forEach(pair => {
103✔
316
            const [key, value] = pair;
55✔
317

318
            newUrl.removeSearch(key);
55✔
319
            newUrl.addSearch(key, value);
55✔
320
        });
321

322
        return newUrl
103✔
323
            .toString();
324
    },
325

326
    /**
327
     * Browsers allow to "Prevent this page form creating additional
328
     * dialogs." checkbox which prevents further input from confirm messages.
329
     * This method falls back to "true" once the user chooses this option.
330
     *
331
     * @method secureConfirm
332
     * @param {String} message to be displayed
333
     * @returns {Boolean}
334
     */
335
    secureConfirm: function secureConfirm(message) {
336
        var start = Number(new Date());
3✔
337
        var result = confirm(message); // eslint-disable-line
3✔
338
        var end = Number(new Date());
3✔
339
        var MINIMUM_DELAY = 10;
3✔
340

341
        return end < start + MINIMUM_DELAY || result === true;
3✔
342
    },
343

344
    /**
345
     * Is localStorage truly supported?
346
     * Check is taken from modernizr.
347
     *
348
     * @property _isStorageSupported
349
     * @private
350
     * @type {Boolean}
351
     */
352
    _isStorageSupported: (function localStorageCheck() {
353
        var mod = 'modernizr';
1✔
354

355
        try {
1✔
356
            localStorage.setItem(mod, mod);
1✔
357
            localStorage.removeItem(mod);
1✔
358
            return true;
1✔
359
        } catch (e) {
360
            // istanbul ignore next
361
            return false;
362
        }
363
    })(),
364

365
    /**
366
     * Adds an event listener to the "CMS".
367
     *
368
     * @method addEventListener
369
     * @param {String} eventName string containing space separated event names
370
     * @param {Function} fn callback to run when the event happens
371
     * @returns {jQuery}
372
     */
373
    addEventListener: function addEventListener(eventName, fn) {
374
        return CMS._eventRoot && CMS._eventRoot.on(_ns(eventName), fn);
102✔
375
    },
376

377
    /**
378
     * Removes the event listener from the "CMS". If a callback is provided - removes only that callback.
379
     *
380
     * @method removeEventListener
381
     * @param {String} eventName string containing space separated event names
382
     * @param {Function} [fn] specific callback to be removed
383
     * @returns {jQuery}
384
     */
385
    removeEventListener: function removeEventListener(eventName, fn) {
386
        return CMS._eventRoot && CMS._eventRoot.off(_ns(eventName), fn);
44✔
387
    },
388

389
    /**
390
     * Dispatches an event
391
     * @method dispatchEvent
392
     * @param {String} eventName event name
393
     * @param {Object} payload whatever payload required for the consumer
394
     * @returns {$.Event} event that was just triggered
395
     */
396
    dispatchEvent: function dispatchEvent(eventName, payload) {
397
        var event = new $.Event(_ns(eventName));
139✔
398

399
        CMS._eventRoot.trigger(event, [payload]);
139✔
400
        return event;
139✔
401
    },
402

403
    /**
404
     * Prevents scrolling with touch in an element.
405
     *
406
     * @method preventTouchScrolling
407
     * @param {jQuery} element element where we are preventing the scroll
408
     * @param {String} namespace so we don't mix events from two different places on the same element
409
     */
410
    preventTouchScrolling: function preventTouchScrolling(element, namespace) {
411
        element.on('touchmove.cms.preventscroll.' + namespace, function(e) {
21✔
412
            e.preventDefault();
1✔
413
        });
414
    },
415

416
    /**
417
     * Allows scrolling with touch in an element.
418
     *
419
     * @method allowTouchScrolling
420
     * @param {jQuery} element element where we are allowing the scroll again
421
     * @param {String} namespace so we don't accidentally remove events from a different handler
422
     */
423
    allowTouchScrolling: function allowTouchScrolling(element, namespace) {
424
        element.off('touchmove.cms.preventscroll.' + namespace);
6✔
425
    },
426

427
    /**
428
     * Returns window object.
429
     *
430
     * @method _getWindow
431
     * @private
432
     * @returns {Window}
433
     */
434
    _getWindow: function() {
435
        return window;
219✔
436
    },
437

438
    /**
439
     * We need to update the url with cms_path param for undo/redo
440
     *
441
     * @function updateUrlWithPath
442
     * @private
443
     * @param {String} url url
444
     * @returns {String} modified url
445
     */
446
    updateUrlWithPath: function(url) {
447
        var win = this._getWindow();
37✔
448
        var path = win.location.pathname + win.location.search;
37✔
449

450
        return this.makeURL(url, [['cms_path', path]]);
37✔
451
    },
452

453
    /**
454
     * Get color scheme either from :root[data-theme] or user system setting
455
     *
456
     * @method get_color_scheme
457
     * @public
458
     * @returns {String}
459
     */
460
    getColorScheme: function () {
461
        let state = $('html').attr('data-theme');
3✔
462

463
        if (!state) {
3!
464
            state = localStorage.getItem('theme') || CMS.config.color_scheme || 'auto';
×
465
        }
466
        return state;
3✔
467
    },
468

469
    /**
470
     * Sets the color scheme for the current document and all iframes contained.
471
     *
472
     * @method setColorScheme
473
     * @public
474
     * @param scheme {String}
475
     * @returns {void}
476
     */
477

478
    setColorScheme: function (mode) {
479
        let body = $('html');
4✔
480
        let scheme = (mode !== 'light' && mode !== 'dark') ? 'auto' : mode;
4✔
481

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

488
        body.attr('data-theme', scheme);
4✔
489
        body.find('div.cms iframe').each(function setFrameColorScheme(i, e) {
4✔
490
            if (e.contentDocument) {
×
491
                e.contentDocument.documentElement.dataset.theme = scheme;
×
492
                // ckeditor (and potentially other apps) have iframes inside their admin forms
493
                // also set color scheme there
494
                $(e.contentDocument).find('iframe').each(setFrameColorScheme);
×
495
            }
496
        });
497
    },
498

499
    /**
500
     * Cycles the color scheme for the current document and all iframes contained.
501
     * Follows the logic introduced in Django's 4.2 admin
502
     *
503
     * @method setColorScheme
504
     * @public}
505
     * @returns {void}
506
     */
507
    toggleColorScheme: function () {
508
        const currentTheme = this.getColorScheme();
×
509
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
×
510

511
        if (prefersDark) {
×
512
            // Auto (dark) -> Light -> Dark
513
            if (currentTheme === 'auto') {
×
514
                this.setColorScheme('light');
×
515
            } else if (currentTheme === 'light') {
×
516
                this.setColorScheme('dark');
×
517
            } else {
518
                this.setColorScheme('auto');
×
519
            }
520
        } else {
521
            // Auto (light) -> Dark -> Light
522
            // eslint-disable-next-line no-lonely-if
523
            if (currentTheme === 'auto') {
×
524
                this.setColorScheme('dark');
×
525
            } else if (currentTheme === 'dark') {
×
526
                this.setColorScheme('light');
×
527
            } else {
528
                this.setColorScheme('auto');
×
529
            }
530
        }
531
    }
532
};
533

534

535
/**
536
 * Provides key codes for common keys.
537
 *
538
 * @module CMS
539
 * @submodule CMS.KEYS
540
 * @example
541
 *     if (e.keyCode === CMS.KEYS.ENTER) { ... };
542
 */
543
export const KEYS = {
1✔
544
    SHIFT: 16,
545
    TAB: 9,
546
    UP: 38,
547
    DOWN: 40,
548
    ENTER: 13,
549
    SPACE: 32,
550
    ESC: 27,
551
    CMD_LEFT: 91,
552
    CMD_RIGHT: 93,
553
    CMD_FIREFOX: 224,
554
    CTRL: 17
555
};
556

557
// shorthand for jQuery(document).ready();
558
$(function() {
1✔
559
    CMS._eventRoot = $('#cms-top');
1✔
560
    // autoinits
561
    Helpers.preventSubmit();
1✔
562
});
563

564
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