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

divio / django-cms / #29532

19 Mar 2025 12:12PM UTC coverage: 75.103%. Remained the same
#29532

push

travis-ci

web-flow
Merge 27c2287f9 into 888ee6eee

1070 of 1617 branches covered (66.17%)

31 of 35 new or added lines in 3 files covered. (88.57%)

69 existing lines in 3 files now uncovered.

2564 of 3414 relevant lines covered (75.1%)

26.3 hits per line

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

85.48
/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
282✔
22
        .split(/\s+/g)
23
        .map(function(eventName) {
24
            return 'cms-' + eventName;
397✔
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 && data.plugin_id ? (data.action || 'ADD').toUpperCase() : '';
4✔
122

123
        switch (action) {
4!
124
            case 'CHANGE':
125
            case 'EDIT':
126
                if (window.CMS._instances.some(plugin =>
1!
127
                    Number(plugin.options.plugin_id) === Number(data.plugin_id) &&
1✔
128
                    plugin.options.type === 'plugin')
129
                ) {
130
                    CMS.API.StructureBoard.invalidateState('EDIT', data);
1✔
131
                    return;
1✔
132
                }
NEW
133
                break;
×
134
            case 'ADD':
135
            case 'DELETE':
136
            case 'CLEAR_PLACEHOLDER':
137
                CMS.API.StructureBoard.invalidateState(action, data);
2✔
138
                return;
2✔
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
     * Assigns an event handler to forms located in the toolbar
151
     * to prevent multiple submissions.
152
     *
153
     * @method preventSubmit
154
     */
155
    preventSubmit: function() {
156
        var forms = $('.cms-toolbar').find('form');
2✔
157
        var SUBMITTED_OPACITY = 0.5;
2✔
158

159
        forms.submit(function() {
2✔
160
            // show loader
161
            showLoader();
1✔
162
            // we cannot use disabled as the name action will be ignored
163
            $('input[type="submit"]')
1✔
164
                .on('click', function(e) {
165
                    e.preventDefault();
2✔
166
                })
167
                .css('opacity', SUBMITTED_OPACITY);
168
        });
169
    },
170

171
    /**
172
     * Sets csrf token header on ajax requests.
173
     *
174
     * @method csrf
175
     * @param {String} csrf_token
176
     */
177
    csrf: function(csrf_token) {
178
        $.ajaxSetup({
22✔
179
            beforeSend: function(xhr) {
180
                xhr.setRequestHeader('X-CSRFToken', csrf_token);
34✔
181
            }
182
        });
183
    },
184

185
    /**
186
     * Sends or retrieves a JSON from localStorage
187
     * or the session (through synchronous ajax request)
188
     * if localStorage is not available. Does not merge with
189
     * previous setSettings calls.
190
     *
191
     * @method setSettings
192
     * @param {Object} newSettings
193
     * @returns {Object}
194
     */
195
    setSettings: function(newSettings) {
196
        // merge settings
197
        var settings = JSON.stringify($.extend({}, window.CMS.config.settings, newSettings));
119✔
198

199
        // use local storage or session
200
        if (this._isStorageSupported) {
119✔
201
            // save within local storage
202
            localStorage.setItem('cms_cookie', settings);
114✔
203
        } else {
204
            // save within session
205
            CMS.API.locked = true;
5✔
206
            showLoader();
5✔
207

208
            $.ajax({
5✔
209
                async: false,
210
                type: 'POST',
211
                url: window.CMS.config.urls.settings,
212
                data: {
213
                    csrfmiddlewaretoken: window.CMS.config.csrf,
214
                    settings: settings
215
                },
216
                success: function(data) {
217
                    CMS.API.locked = false;
3✔
218
                    // determine if logged in or not
219
                    settings = data ? JSON.parse(data) : window.CMS.config.settings;
3✔
220
                    hideLoader();
3✔
221
                },
222
                error: function(jqXHR) {
223
                    CMS.API.Messages.open({
2✔
224
                        message: jqXHR.responseText + ' | ' + jqXHR.status + ' ' + jqXHR.statusText,
225
                        error: true
226
                    });
227
                }
228
            });
229
        }
230

231
        // save settings
232
        CMS.settings = typeof settings === 'object' ? settings : JSON.parse(settings);
119✔
233

234
        // ensure new settings are returned
235
        return CMS.settings;
119✔
236
    },
237

238
    /**
239
     * Gets user settings (from localStorage or the session)
240
     * in the same way as setSettings sets them.
241
     *
242
     * @method getSettings
243
     * @returns {Object}
244
     */
245
    getSettings: function() {
246
        var settings;
247

248

249
        // use local storage or session
250
        if (this._isStorageSupported) {
9✔
251
            // get from local storage
252
            settings = JSON.parse(localStorage.getItem('cms_cookie') || 'null');
6✔
253
        } else {
254
            showLoader();
3✔
255
            CMS.API.locked = true;
3✔
256
            // get from session
257
            $.ajax({
3✔
258
                async: false,
259
                type: 'GET',
260
                url: window.CMS.config.urls.settings,
261
                success: function(data) {
262
                    CMS.API.locked = false;
2✔
263
                    // determine if logged in or not
264
                    settings = data ? JSON.parse(data) : window.CMS.config.settings;
2✔
265
                    hideLoader();
2✔
266
                },
267
                error: function(jqXHR) {
268
                    CMS.API.Messages.open({
1✔
269
                        message: jqXHR.responseText + ' | ' + jqXHR.status + ' ' + jqXHR.statusText,
270
                        error: true
271
                    });
272
                }
273
            });
274
        }
275

276
        // edit_off is a random flag that should be available on the page, but sometimes can
277
        // be not set when settings are carried over from pagetree
278
        if (
9✔
279
            (!settings || !currentVersionMatches(settings))
15✔
280
        ) {
281
            settings = this.setSettings(window.CMS.config.settings);
7✔
282
        }
283

284
        // save settings
285
        CMS.settings = settings;
9✔
286

287
        // ensure new settings are returned
288
        return CMS.settings;
9✔
289
    },
290

291
    /**
292
     * Modifies the url with new params and sanitises the url
293
     * reversing any & to ampersand (introduced with #3404)
294
     *
295
     * @method makeURL
296
     * @param {String} url original url
297
     * @param {Array[]} [params] array of [`param`, `value`] arrays to update the url
298
     * @returns {String}
299
     */
300
    makeURL: function makeURL(url, params = []) {
53✔
301
        let newUrl = new URL(URL.decode(url.replace(/&/g, '&')));
103✔
302

303
        params.forEach(pair => {
103✔
304
            const [key, value] = pair;
55✔
305

306
            newUrl.removeSearch(key);
55✔
307
            newUrl.addSearch(key, value);
55✔
308
        });
309

310
        return newUrl
103✔
311
            .toString();
312
    },
313

314
    /**
315
     * Browsers allow to "Prevent this page form creating additional
316
     * dialogs." checkbox which prevents further input from confirm messages.
317
     * This method falls back to "true" once the user chooses this option.
318
     *
319
     * @method secureConfirm
320
     * @param {String} message to be displayed
321
     * @returns {Boolean}
322
     */
323
    secureConfirm: function secureConfirm(message) {
324
        var start = Number(new Date());
3✔
325
        var result = confirm(message); // eslint-disable-line
3✔
326
        var end = Number(new Date());
3✔
327
        var MINIMUM_DELAY = 10;
3✔
328

329
        return end < start + MINIMUM_DELAY || result === true;
3✔
330
    },
331

332
    /**
333
     * Is localStorage truly supported?
334
     * Check is taken from modernizr.
335
     *
336
     * @property _isStorageSupported
337
     * @private
338
     * @type {Boolean}
339
     */
340
    _isStorageSupported: (function localStorageCheck() {
341
        var mod = 'modernizr';
1✔
342

343
        try {
1✔
344
            localStorage.setItem(mod, mod);
1✔
345
            localStorage.removeItem(mod);
1✔
346
            return true;
1✔
347
        } catch (e) {
348
            // istanbul ignore next
349
            return false;
350
        }
351
    })(),
352

353
    /**
354
     * Adds an event listener to the "CMS".
355
     *
356
     * @method addEventListener
357
     * @param {String} eventName string containing space separated event names
358
     * @param {Function} fn callback to run when the event happens
359
     * @returns {jQuery}
360
     */
361
    addEventListener: function addEventListener(eventName, fn) {
362
        return CMS._eventRoot && CMS._eventRoot.on(_ns(eventName), fn);
102✔
363
    },
364

365
    /**
366
     * Removes the event listener from the "CMS". If a callback is provided - removes only that callback.
367
     *
368
     * @method removeEventListener
369
     * @param {String} eventName string containing space separated event names
370
     * @param {Function} [fn] specific callback to be removed
371
     * @returns {jQuery}
372
     */
373
    removeEventListener: function removeEventListener(eventName, fn) {
374
        return CMS._eventRoot && CMS._eventRoot.off(_ns(eventName), fn);
44✔
375
    },
376

377
    /**
378
     * Dispatches an event
379
     * @method dispatchEvent
380
     * @param {String} eventName event name
381
     * @param {Object} payload whatever payload required for the consumer
382
     * @returns {$.Event} event that was just triggered
383
     */
384
    dispatchEvent: function dispatchEvent(eventName, payload) {
385
        var event = new $.Event(_ns(eventName));
140✔
386

387
        CMS._eventRoot.trigger(event, [payload]);
140✔
388
        return event;
140✔
389
    },
390

391
    /**
392
     * Prevents scrolling with touch in an element.
393
     *
394
     * @method preventTouchScrolling
395
     * @param {jQuery} element element where we are preventing the scroll
396
     * @param {String} namespace so we don't mix events from two different places on the same element
397
     */
398
    preventTouchScrolling: function preventTouchScrolling(element, namespace) {
399
        element.on('touchmove.cms.preventscroll.' + namespace, function(e) {
21✔
400
            e.preventDefault();
1✔
401
        });
402
    },
403

404
    /**
405
     * Allows scrolling with touch in an element.
406
     *
407
     * @method allowTouchScrolling
408
     * @param {jQuery} element element where we are allowing the scroll again
409
     * @param {String} namespace so we don't accidentally remove events from a different handler
410
     */
411
    allowTouchScrolling: function allowTouchScrolling(element, namespace) {
412
        element.off('touchmove.cms.preventscroll.' + namespace);
6✔
413
    },
414

415
    /**
416
     * Returns window object.
417
     *
418
     * @method _getWindow
419
     * @private
420
     * @returns {Window}
421
     */
422
    _getWindow: function() {
423
        return window;
220✔
424
    },
425

426
    /**
427
     * We need to update the url with cms_path param for undo/redo
428
     *
429
     * @function updateUrlWithPath
430
     * @private
431
     * @param {String} url url
432
     * @returns {String} modified url
433
     */
434
    updateUrlWithPath: function(url) {
435
        var win = this._getWindow();
37✔
436
        var path = win.location.pathname + win.location.search;
37✔
437

438
        return this.makeURL(url, [['cms_path', path]]);
37✔
439
    },
440

441
    /**
442
     * Get color scheme either from :root[data-theme] or user system setting
443
     *
444
     * @method get_color_scheme
445
     * @public
446
     * @returns {String}
447
     */
448
    getColorScheme: function () {
449
        let state = $('html').attr('data-theme');
3✔
450

451
        if (!state) {
3!
UNCOV
452
            state = localStorage.getItem('theme') || CMS.config.color_scheme || 'auto';
×
453
        }
454
        return state;
3✔
455
    },
456

457
    /**
458
     * Sets the color scheme for the current document and all iframes contained.
459
     *
460
     * @method setColorScheme
461
     * @public
462
     * @param scheme {String}
463
     * @returns {void}
464
     */
465

466
    setColorScheme: function (mode) {
467
        let body = $('html');
4✔
468
        let scheme = (mode !== 'light' && mode !== 'dark') ? 'auto' : mode;
4✔
469

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

476
        body.attr('data-theme', scheme);
4✔
477
        body.find('div.cms iframe').each(function setFrameColorScheme(i, e) {
4✔
UNCOV
478
            if (e.contentDocument) {
×
UNCOV
479
                e.contentDocument.documentElement.dataset.theme = scheme;
×
480
                // ckeditor (and potentially other apps) have iframes inside their admin forms
481
                // also set color scheme there
UNCOV
482
                $(e.contentDocument).find('iframe').each(setFrameColorScheme);
×
483
            }
484
        });
485
    },
486

487
    /**
488
     * Cycles the color scheme for the current document and all iframes contained.
489
     * Follows the logic introduced in Django's 4.2 admin
490
     *
491
     * @method setColorScheme
492
     * @public}
493
     * @returns {void}
494
     */
495
    toggleColorScheme: function () {
UNCOV
496
        const currentTheme = this.getColorScheme();
×
UNCOV
497
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
×
498

UNCOV
499
        if (prefersDark) {
×
500
            // Auto (dark) -> Light -> Dark
UNCOV
501
            if (currentTheme === 'auto') {
×
UNCOV
502
                this.setColorScheme('light');
×
UNCOV
503
            } else if (currentTheme === 'light') {
×
UNCOV
504
                this.setColorScheme('dark');
×
505
            } else {
506
                this.setColorScheme('auto');
×
507
            }
508
        } else {
509
            // Auto (light) -> Dark -> Light
510
            // eslint-disable-next-line no-lonely-if
511
            if (currentTheme === 'auto') {
×
512
                this.setColorScheme('dark');
×
513
            } else if (currentTheme === 'dark') {
×
UNCOV
514
                this.setColorScheme('light');
×
515
            } else {
UNCOV
516
                this.setColorScheme('auto');
×
517
            }
518
        }
519
    }
520
};
521

522

523
/**
524
 * Provides key codes for common keys.
525
 *
526
 * @module CMS
527
 * @submodule CMS.KEYS
528
 * @example
529
 *     if (e.keyCode === CMS.KEYS.ENTER) { ... };
530
 */
531
export const KEYS = {
1✔
532
    SHIFT: 16,
533
    TAB: 9,
534
    UP: 38,
535
    DOWN: 40,
536
    ENTER: 13,
537
    SPACE: 32,
538
    ESC: 27,
539
    CMD_LEFT: 91,
540
    CMD_RIGHT: 93,
541
    CMD_FIREFOX: 224,
542
    CTRL: 17
543
};
544

545
// shorthand for jQuery(document).ready();
546
$(function() {
1✔
547
    CMS._eventRoot = $('#cms-top');
1✔
548
    // autoinits
549
    Helpers.preventSubmit();
1✔
550
});
551

552
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