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

divio / django-cms / #30128

15 Nov 2025 10:40AM UTC coverage: 89.757% (-0.06%) from 89.818%
#30128

push

travis-ci

web-flow
Merge 5306237b1 into d306559f3

1335 of 2146 branches covered (62.21%)

14 of 40 new or added lines in 3 files covered. (35.0%)

15 existing lines in 2 files now uncovered.

9218 of 10270 relevant lines covered (89.76%)

11.15 hits per line

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

34.62
/cms/static/cms/js/modules/cms.pagetree.js
1
/*
2
 * Copyright https://github.com/divio/django-cms
3
 */
4

5
import $ from 'jquery';
6

7
import { Helpers, KEYS } from './cms.base';
8
import PageTreeDropdowns from './cms.pagetree.dropdown';
9
import PageTreeStickyHeader from './cms.pagetree.stickyheader';
10
// switched from commonjs 'lodash' bundle to per-method ESM imports for better tree-shaking
11
import debounce from 'lodash-es/debounce.js';
12
import without from 'lodash-es/without.js';
13

14
import 'jstree';
15
import '../libs/jstree/jstree.grid.min';
16

17
/**
18
 * The pagetree is loaded via `/admin/cms/page` and has a custom admin
19
 * templates stored within `templates/admin/cms/page/tree`.
20
 *
21
 * @class PageTree
22
 * @namespace CMS
23
 */
24
class PageTree {
25
    constructor(options) {
26
        // options are loaded from the pagetree html node
27
        var opts = $('.js-cms-pagetree').data('json');
21✔
28

29
        this.options = $.extend(true, {}, {
21✔
30
            pasteSelector: '.js-cms-tree-item-paste'
31
        }, opts, options);
32

33
        // states and events
34
        this.click = 'click.cms.pagetree';
21✔
35
        this.clipboard = {
21✔
36
            id: null,
37
            origin: null,
38
            type: ''
39
        };
40
        this.successTimer = 1000;
21✔
41

42
        // elements
43
        this._setupUI();
21✔
44
        this._events();
21✔
45

46
        Helpers.csrf(this.options.csrf);
21✔
47

48
        this._setupLanguages();
21✔
49

50
        // cancel if pagetree is not available
51
        if ($.isEmptyObject(opts) || opts.empty) {
21✔
52
            this._getClipboard();
1✔
53
            // attach events to paste
54
            var that = this;
1✔
55

56
            this.ui.container.on(this.click, this.options.pasteSelector, function(e) {
1✔
57
                e.preventDefault();
×
58
                if ($(this).hasClass('cms-pagetree-dropdown-item-disabled')) {
×
59
                    return;
×
60
                }
61
                that._paste(e);
×
62
            });
63
        } else {
64
            this._setup();
20✔
65
        }
66
    }
67

68
    /**
69
     * Stores all jQuery references within `this.ui`.
70
     *
71
     * @method _setupUI
72
     * @private
73
     */
74
    _setupUI() {
75
        var pagetree = $('.cms-pagetree');
21✔
76

77
        this.ui = {
21✔
78
            container: pagetree,
79
            document: $(document),
80
            tree: pagetree.find('.js-cms-pagetree'),
81
            dialog: $('.js-cms-tree-dialog'),
82
            siteForm: $('.js-cms-pagetree-site-form'),
83
            languagesSelect: $('.js-cms-pagetree-languages')
84
        };
85
    }
86

87
    _setupLanguages() {
88
        this.ui.languagesSelect.on('change', () => {
21✔
89
            const newLanguage = this.ui.languagesSelect.val();
×
90

91
            // eslint-disable-next-line no-undef
92
            const url = new URL(window.location.href);
×
93

94
            url.searchParams.delete('language');
×
95
            url.searchParams.set('language', newLanguage);
×
96

97
            window.location.href = url.toString();
×
98
        });
99
    }
100

101
    /**
102
     * Setting up the jstree and the related columns.
103
     *
104
     * @method _setup
105
     * @private
106
     */
107
    _setup() {
108
        var that = this;
20✔
109
        var columns = [];
20✔
110
        var obj = {
20✔
111
            language: this.options.lang.code,
112
            openNodes: []
113
        };
114
        var data = false;
20✔
115

116
        // setup column headings
117
        // eslint-disable-next-line no-shadow
118
        $.each(this.options.columns, function(index, obj) {
20✔
119
            if (obj.key === '') {
200✔
120
                // the first row is already populated, to avoid overwrites
121
                // just leave the "key" param empty
122
                columns.push({
20✔
123
                    wideValueClass: obj.wideValueClass,
124
                    wideValueClassPrefix: obj.wideValueClassPrefix,
125
                    header: obj.title,
126
                    width: obj.width || '1%',
20!
127
                    wideCellClass: obj.cls
128
                });
129
            } else {
130
                columns.push({
180✔
131
                    wideValueClass: obj.wideValueClass,
132
                    wideValueClassPrefix: obj.wideValueClassPrefix,
133
                    header: obj.title,
134
                    value: function(node) {
135
                        // it needs to have the "colde" format and not "col-de"
136
                        // as jstree will convert "col-de" to "colde"
137
                        // also we strip dashes, in case language code contains it
138
                        // e.g. zh-hans, zh-cn etc
139
                        if (node.data) {
×
140
                            return node.data['col' + obj.key.replace('-', '')];
×
141
                        }
142

143
                        return '';
×
144
                    },
145
                    width: obj.width || '1%',
360✔
146
                    wideCellClass: obj.cls
147
                });
148
            }
149
        });
150

151
        // prepare data
152
        if (!this.options.filtered) {
20!
153
            data = {
20✔
154
                url: this.options.urls.tree,
155
                cache: false,
156
                data: function(node) {
157
                    // '#' is rendered if its the root node, there we only
158
                    // care about `obj.openNodes`, in the following case
159
                    // we are requesting a specific node
160
                    if (node.id === '#') {
20!
161
                        obj.nodeId = null;
20✔
162
                    } else {
163
                        obj.nodeId = that._storeNodeId(node.data.nodeId);
×
164
                    }
165

166
                    // we need to store the opened items inside the localstorage
167
                    // as we have to load the pagetree with the previous opened
168
                    // state
169
                    obj.openNodes = that._getStoredNodeIds();
20✔
170

171
                    // we need to set the site id to get the correct tree
172
                    obj.site = that.options.site;
20✔
173

174
                    return obj;
20✔
175
                }
176
            };
177
        }
178

179
        // bind options to the jstree instance
180
        this.ui.tree.jstree({
20✔
181
            core: {
182
                // disable open/close animations
183
                animation: 0,
184
                // core setting to allow actions
185
                // eslint-disable-next-line max-params
186
                check_callback: function(operation, node, node_parent, node_position, more) {
187
                    if ((operation === 'move_node' || operation === 'copy_node') && more && more.pos) {
×
188
                        if (more.pos === 'i') {
×
189
                            $('#jstree-marker').addClass('jstree-marker-child');
×
190
                        } else {
191
                            $('#jstree-marker').removeClass('jstree-marker-child');
×
192
                        }
193
                    }
194

195
                    return that._hasPermission(node_parent, 'add');
×
196
                },
197
                // https://www.jstree.com/api/#/?f=$.jstree.defaults.core.data
198
                data: data,
199
                // strings used within jstree that are called using `get_string`
200
                strings: {
201
                    'Loading ...': this.options.lang.loading,
202
                    'New node': this.options.lang.newNode,
203
                    nodes: this.options.lang.nodes
204
                },
205
                error: function(error) {
206
                    // ignore warnings about dragging parent into child
207
                    var errorData = JSON.parse(error.data);
×
208

209
                    if (error.error === 'check' && errorData && errorData.chk === 'move_node') {
×
210
                        return;
×
211
                    }
212
                    that.showError(error.reason);
×
213
                },
214
                themes: {
215
                    name: 'django-cms'
216
                },
217
                // disable the multi selection of nodes for now
218
                multiple: false
219
            },
220
            // activate drag and drop plugin
221
            plugins: ['dnd', 'search', 'grid'],
222
            // https://www.jstree.com/api/#/?f=$.jstree.defaults.dnd
223
            dnd: {
224
                inside_pos: 'last',
225
                // disable the multi selection of nodes for now
226
                drag_selection: false,
227
                // disable dragging if filtered
228
                is_draggable: function(nodes) {
229
                    return that._hasPermission(nodes[0], 'move') && !that.options.filtered;
×
230
                },
231
                large_drop_target: true,
232
                copy: true,
233
                touch: 'selected'
234
            },
235
            // https://github.com/deitch/jstree-grid
236
            grid: {
237
                // columns are provided from base.html options
238
                width: '100%',
239
                columns: columns
240
            }
241
        });
242
    }
243

244
    /**
245
     * Sets up all the event handlers, such as opening and moving.
246
     *
247
     * @method _events
248
     * @private
249
     */
250
    _events() {
251
        var that = this;
21✔
252

253
        // set events for the nodeId updates
254
        this.ui.tree.on('after_close.jstree', function(e, el) {
21✔
255
            that._removeNodeId(el.node.data.nodeId);
×
256
        });
257

258
        this.ui.tree.on('after_open.jstree', function(e, el) {
21✔
259
            that._storeNodeId(el.node.data.nodeId);
×
260

261
            // `after_open` event can be triggered when pasting
262
            // is in progress (meaning we are pasting into a leaf node
263
            // in this case we do not need to update helpers state
264
            if (this.clipboard && !this.clipboard.isPasting) {
×
265
                that._updatePasteHelpersState();
×
266
            }
267
        });
268

269
        this.ui.document.on('keydown.pagetree.alt-mode', function(e) {
21✔
270
            if (e.keyCode === KEYS.SHIFT) {
466!
271
                that.ui.container.addClass('cms-pagetree-alt-mode');
×
272
            }
273
        });
274

275
        this.ui.document.on('keyup.pagetree.alt-mode', function(e) {
21✔
276
            if (e.keyCode === KEYS.SHIFT) {
463!
277
                that.ui.container.removeClass('cms-pagetree-alt-mode');
×
278
            }
279
        });
280

281
        $(window)
21✔
282
            .on(
283
                'mousemove.pagetree.alt-mode',
284
                debounce(function(e) {
285
                    if (e.shiftKey) {
1!
286
                        that.ui.container.addClass('cms-pagetree-alt-mode');
×
287
                    } else {
288
                        that.ui.container.removeClass('cms-pagetree-alt-mode');
1✔
289
                    }
290
                }, 200) // eslint-disable-line no-magic-numbers
291
            )
292
            .on('blur.cms', () => {
293
                that.ui.container.removeClass('cms-pagetree-alt-mode');
21✔
294
            });
295

296
        this.ui.document.on('dnd_start.vakata', function(e, data) {
21✔
297
            var element = $(data.element);
×
298
            var node = element.parent();
×
299

300
            that._dropdowns.closeAllDropdowns();
×
301

302
            node.addClass('jstree-is-dragging');
×
303
            data.data.nodes.forEach(function(nodeId) {
×
304
                var descendantIds = that._getDescendantsIds(nodeId);
×
305

306
                [nodeId].concat(descendantIds).forEach(function(id) {
×
307
                    $('.jsgrid_' + id + '_col').addClass('jstree-is-dragging');
×
308
                });
309
            });
310

311
            if (!node.hasClass('jstree-leaf')) {
×
312
                data.helper.addClass('is-stacked');
×
313
            }
314
        });
315

316
        var isCopyClassAdded = false;
21✔
317

318
        this.ui.document.on('dnd_move.vakata', function(e, data) {
21✔
319
            var isMovingCopy =
320
                data.data.origin &&
×
321
                (data.data.origin.settings.dnd.always_copy ||
322
                    (data.data.origin.settings.dnd.copy && (data.event.metaKey || data.event.ctrlKey)));
323

324
            if (isMovingCopy) {
×
325
                if (!isCopyClassAdded) {
×
326
                    $('.jstree-is-dragging').addClass('jstree-is-dragging-copy');
×
327
                    isCopyClassAdded = true;
×
328
                }
329
            } else if (isCopyClassAdded) {
×
330
                $('.jstree-is-dragging').removeClass('jstree-is-dragging-copy');
×
331
                isCopyClassAdded = false;
×
332
            }
333

334
            // styling the #jstree-marker dynamically on dnd_move.vakata
335
            // because jsTree doesn't support RTL on this specific case
336
            // and sets the 'left' property without checking document direction
337
            var ins = $.jstree.reference(data.event.target);
×
338

339
            // make sure we're hovering over a tree node
340
            if (ins) {
×
341
                var marker = $('#jstree-marker');
×
342
                var root = $('#changelist');
×
343
                var column = $(data.data.origin.element);
×
344

345
                var hover = ins.settings.dnd.large_drop_target ?
×
346
                    $(data.event.target)
347
                        .closest('.jstree-node') :
348
                    $(data.event.target)
349
                        .closest('.jstree-anchor').parent();
350

351
                var width = root.width() - (column.width() - hover.width());
×
352

353
                marker.css({
×
354
                    left: `${root.offset().left}px`,
355
                    width: `${width}px`
356
                });
357
            }
358
        });
359

360
        this.ui.document.on('dnd_stop.vakata', function(e, data) {
21✔
361
            var element = $(data.element);
×
362
            var node = element.parent();
×
363

364
            node.removeClass('jstree-is-dragging jstree-is-dragging-copy');
×
365
            data.data.nodes.forEach(function(nodeId) {
×
366
                var descendantIds = that._getDescendantsIds(nodeId);
×
367

368
                [nodeId].concat(descendantIds).forEach(function(id) {
×
369
                    $('.jsgrid_' + id + '_col').removeClass('jstree-is-dragging jstree-is-dragging-copy');
×
370
                });
371
            });
372
        });
373

374
        // store moved position node
375
        this.ui.tree.on('move_node.jstree copy_node.jstree', function(e, obj) {
21✔
376
            if ((!that.clipboard.type && e.type !== 'copy_node') || that.clipboard.type === 'cut') {
×
377
                that._moveNode(that._getNodePosition(obj)).done(function() {
×
378
                    var instance = that.ui.tree.jstree(true);
×
379

380
                    instance._hide_grid(instance.get_node(obj.parent));
×
381
                    if (obj.parent === '#' || (obj.node && obj.node.data && obj.node.data.isHome)) {
×
382
                        instance.refresh();
×
383
                    } else {
384
                        // have to refresh parent, because refresh only
385
                        // refreshes children of the node, never the node itself
386
                        instance.refresh_node(obj.parent);
×
387
                    }
388
                });
389
            } else {
390
                that._copyNode(obj);
×
391
            }
392
            // we need to open the parent node if we trigger an element
393
            // if not already opened
394
            that.ui.tree.jstree('open_node', obj.parent);
×
395
        });
396

397
        // set event for cut and paste
398
        this.ui.container.on(this.click, '.js-cms-tree-item-cut', function(e) {
21✔
399
            e.preventDefault();
×
400
            that._cutOrCopy({ type: 'cut', element: $(this) });
×
401
        });
402

403
        // set event for cut and paste
404
        this.ui.container.on(this.click, '.js-cms-tree-item-copy', function(e) {
21✔
405
            e.preventDefault();
×
406
            that._cutOrCopy({ type: 'copy', element: $(this) });
×
407
        });
408

409
        // attach events to paste
410
        this.ui.container.on(this.click, this.options.pasteSelector, function(e) {
21✔
411
            e.preventDefault();
×
412
            if ($(this).hasClass('cms-pagetree-dropdown-item-disabled')) {
×
413
                return;
×
414
            }
415
            that._paste(e);
×
416
        });
417

418
        // advanced settings link handling
419
        this.ui.container.on(this.click, '.js-cms-tree-advanced-settings', function(e) {
21✔
420
            if (e.shiftKey) {
×
421
                e.preventDefault();
×
422
                var link = $(this);
×
423

424
                if (link.data('url')) {
×
425
                    window.location.href = link.data('url');
×
426
                }
427
            }
428
        });
429

430
        // when adding new pages - expand nodes as well
431
        this.ui.container.on(this.click, '.js-cms-pagetree-add-page', e => {
21✔
432
            const treeId = this._getNodeId($(e.target));
×
433

434
            const nodeData = this.ui.tree.jstree('get_node', treeId);
×
435

436
            this._storeNodeId(nodeData.data.id);
×
437
        });
438

439
        // add events for error reload (messagelist)
440
        this.ui.document.on(this.click, '.messagelist .cms-tree-reload', function(e) {
21✔
441
            e.preventDefault();
×
442
            that._reloadHelper();
×
443
        });
444

445
        // propagate the sites dropdown "li > a" entries to the hidden sites form
446
        this.ui.container.find('.js-cms-pagetree-site-trigger').on(this.click, function(e) {
21✔
447
            e.preventDefault();
×
448
            var el = $(this);
×
449

450
            // prevent if parent is active
451
            if (el.parent().hasClass('active')) {
×
452
                return false;
×
453
            }
454
            that.ui.siteForm.find('select').val(el.data().id).end().submit();
×
455
        });
456

457
        // additional event handlers
458
        this._setupDropdowns();
21✔
459
        this._setupSearch();
21✔
460

461
        // make sure ajax post requests are working
462
        this._setAjaxPost('.js-cms-tree-item-menu a');
21✔
463
        this._setAjaxPost('.js-cms-tree-lang-trigger');
21✔
464
        this._setAjaxPost('.js-cms-tree-item-set-home a');
21✔
465

466
        this._setupPageView();
21✔
467
        this._setupStickyHeader();
21✔
468

469
        this.ui.tree.on('ready.jstree', () => this._getClipboard());
21✔
470
    }
471

472
    _getClipboard() {
473
        this.clipboard = CMS.settings.pageClipboard || this.clipboard;
1✔
474

475
        if (this.clipboard.type && this.clipboard.origin) {
1!
476
            this._enablePaste();
×
477
            this._updatePasteHelpersState();
×
478
        }
479
    }
480

481
    /**
482
     * Helper to process the cut and copy events.
483
     *
484
     * @method _cutOrCopy
485
     * @param {Object} [obj]
486
     * @param {Number} [obj.type] either 'cut' or 'copy'
487
     * @param {Number} [obj.element] originated trigger element
488
     * @private
489
     * @returns {Boolean|void}
490
     */
491
    _cutOrCopy(obj) {
492
        // prevent actions if you try to copy a page with an apphook
493
        if (obj.type === 'copy' && obj.element.data().apphook) {
×
494
            this.showError(this.options.lang.apphook);
×
495
            return false;
×
496
        }
497

498
        var jsTreeId = this._getNodeId(obj.element.closest('.jstree-grid-cell'));
×
499

500
        // resets if we click again
501
        if (this.clipboard.type === obj.type && jsTreeId === this.clipboard.id) {
×
502
            this.clipboard.type = null;
×
503
            this.clipboard.id = null;
×
504
            this.clipboard.origin = null;
×
505
            this.clipboard.source_site = null;
×
506
            this._disablePaste();
×
507
        } else {
508
            this.clipboard.origin = obj.element.data().id; // this._getNodeId(obj.element);
×
509
            this.clipboard.type = obj.type;
×
510
            this.clipboard.id = jsTreeId;
×
511
            this.clipboard.source_site = this.options.site;
×
512
            this._updatePasteHelpersState();
×
513
        }
514
        if (this.clipboard.type === 'copy' || !this.clipboard.type) {
×
515
            CMS.settings.pageClipboard = this.clipboard;
×
516
            Helpers.setSettings(CMS.settings);
×
517
        }
518
    }
519

520
    /**
521
     * Helper to process the paste event.
522
     *
523
     * @method _paste
524
     * @param {$.Event} event click event
525
     * @private
526
     */
527
    _paste(event) {
528
        // hide helpers after we picked one
529
        this._disablePaste();
5✔
530

531
        var copyFromId = this._getNodeId(
5✔
532
            $(`.js-cms-pagetree-options[data-id="${this.clipboard.origin}"]`).closest('.jstree-grid-cell')
533
        );
534
        var copyToId = this._getNodeId($(event.currentTarget));
5✔
535

536
        if (this.clipboard.source_site === this.options.site) {
5!
537
            if (this.clipboard.type === 'cut') {
×
538
                this.ui.tree.jstree('cut', copyFromId);
×
539
            } else {
540
                this.ui.tree.jstree('copy', copyFromId);
×
541
            }
542

543
            this.clipboard.isPasting = true;
×
544
            this.ui.tree.jstree('paste', copyToId, 'last');
×
545
        } else {
546
            const dummyId = this.ui.tree.jstree('create_node', copyToId, 'Loading', 'last');
5✔
547

548
            if (this.ui.tree.length) {
5!
549
                this.ui.tree.jstree('cut', dummyId);
5✔
550
                this.clipboard.isPasting = true;
5✔
551
                this.ui.tree.jstree('paste', copyToId, 'last');
5✔
552
            } else {
553
                if (this.clipboard.type === 'copy') {
×
554
                    this._copyNode();
×
555
                }
556
                if (this.clipboard.type === 'cut') {
×
557
                    this._moveNode();
×
558
                }
559
            }
560
        }
561

562
        this.clipboard.id = null;
5✔
563
        this.clipboard.type = null;
5✔
564
        this.clipboard.origin = null;
5✔
565
        this.clipboard.isPasting = false;
5✔
566
        CMS.settings.pageClipboard = this.clipboard;
5✔
567
        Helpers.setSettings(CMS.settings);
5✔
568
    }
569

570
    /**
571
     * Retreives a list of nodes from local storage.
572
     *
573
     * @method _getStoredNodeIds
574
     * @private
575
     * @returns {Array} list of ids
576
     */
577
    _getStoredNodeIds() {
578
        return CMS.settings.pagetree || [];
20✔
579
    }
580

581
    /**
582
     * Stores a node in local storage.
583
     *
584
     * @method _storeNodeId
585
     * @private
586
     * @param {String} id to be stored
587
     * @returns {String} id that has been stored
588
     */
589
    _storeNodeId(id) {
590
        var number = id;
×
591
        var storage = this._getStoredNodeIds();
×
592

593
        // store value only if it isn't there yet
594
        if (storage.indexOf(number) === -1) {
×
595
            storage.push(number);
×
596
        }
597

598
        CMS.settings.pagetree = storage;
×
599
        Helpers.setSettings(CMS.settings);
×
600

601
        return number;
×
602
    }
603

604
    /**
605
     * Removes a node in local storage.
606
     *
607
     * @method _removeNodeId
608
     * @private
609
     * @param {String} id to be stored
610
     * @returns {String} id that has been removed
611
     */
612
    _removeNodeId(id) {
613
        const instance = this.ui.tree.jstree(true);
×
614
        const childrenIds = instance.get_node({
×
615
            id: CMS.$(`[data-node-id=${id}]`).attr('id')
616
        }).children_d;
617

618
        const idsToRemove = [id].concat(
×
619
            childrenIds.map(childId => {
620
                const node = instance.get_node({ id: childId });
×
621

622
                if (!node || !node.data) {
×
623
                    return node;
×
624
                }
625

626
                return node.data.nodeId;
×
627
            })
628
        );
629

630
        const storage = without(this._getStoredNodeIds(), ...idsToRemove);
×
631

632
        CMS.settings.pagetree = storage;
×
633
        Helpers.setSettings(CMS.settings);
×
634

635
        return id;
×
636
    }
637

638
    /**
639
     * Moves a node after drag & drop.
640
     *
641
     * @method _moveNode
642
     * @param {Object} [obj]
643
     * @param {Number} [obj.id] current element id for url matching
644
     * @param {Number} [obj.target] target sibling or parent
645
     * @param {Number} [obj.position] either `left`, `right` or `last-child`
646
     * @returns {$.Deferred} ajax request object
647
     * @private
648
     */
649
    _moveNode(obj) {
650
        var that = this;
×
651

652
        if (!obj.id && this.clipboard.type === 'cut' && this.clipboard.origin) {
×
653
            obj.id = this.clipboard.origin;
×
654
            obj.source_site = this.clipboard.source_site;
×
655
        } else {
656
            obj.site = that.options.site;
×
657
        }
658

659
        return $.ajax({
×
660
            method: 'post',
661
            url: that.options.urls.move.replace('{id}', obj.id),
662
            data: obj
663
        })
664
            .done(function(r) {
665
                if (r.status && r.status === 400) { // eslint-disable-line
×
666
                    that.showError(r.content);
×
667
                } else {
668
                    that._showSuccess(obj.id);
×
669
                }
670
            })
671
            .fail(function(error) {
672
                that.showError(error.statusText);
×
673
            });
674
    }
675

676
    /**
677
     * Copies a node into the selected node.
678
     *
679
     * @method _copyNode
680
     * @param {Object} obj page obj
681
     * @private
682
     */
683
    _copyNode(obj) {
684
        var that = this;
×
685
        var node = { position: 0 };
×
686

687
        if (obj) {
×
688
            node = that._getNodePosition(obj);
×
689
        }
690

691
        var data = {
×
692
            // obj.original.data.id is for drag copy
693
            id: this.clipboard.origin || obj.original.data.id,
×
694
            position: node.position
695
        };
696

697
        if (this.clipboard.source_site) {
×
698
            data.source_site = this.clipboard.source_site;
×
699
        } else {
700
            data.source_site = this.options.site;
×
701
        }
702

703
        // if there is no target provided, the node lands in root
704
        if (node.target) {
×
705
            data.target = node.target;
×
706
        }
707

708
        if (that.options.permission) {
×
709
            // we need to load a dialog first, to check if permissions should
710
            // be copied or not
711
            $.ajax({
×
712
                method: 'post',
713
                url: that.options.urls.copyPermission.replace('{id}', data.id),
714
                data: data
715
                // the dialog is loaded via the ajax respons originating from
716
                // `templates/admin/cms/page/tree/copy_premissions.html`
717
            })
718
                .done(function(dialog) {
719
                    that.ui.dialog.append(dialog);
×
720
                })
721
                .fail(function(error) {
722
                    that.showError(error.statusText);
×
723
                });
724

725
            // attach events to the permission dialog
726
            this.ui.dialog
×
727
                .off(this.click, '.cancel')
728
                .on(this.click, '.cancel', function(e) {
729
                    e.preventDefault();
×
730
                    // remove just copied node
731
                    that.ui.tree.jstree('delete_node', obj.node.id);
×
732
                    $('.js-cms-dialog').remove();
×
733
                    $('.js-cms-dialog-dimmer').remove();
×
734
                })
735
                .off(this.click, '.submit')
736
                .on(this.click, '.submit', function(e) {
737
                    e.preventDefault();
×
738
                    var submitButton = $(this);
×
739
                    var formData = submitButton.closest('form').serialize().split('&');
×
740

741
                    submitButton.prop('disabled', true);
×
742

743
                    // loop through form data and attach to obj
744
                    for (var i = 0; i < formData.length; i++) {
×
745
                        data[formData[i].split('=')[0]] = formData[i].split('=')[1];
×
746
                    }
747

748
                    that._saveCopiedNode(data);
×
749
                });
750
        } else {
751
            this._saveCopiedNode(data);
×
752
        }
753
    }
754

755
    /**
756
     * Sends the request to copy a node.
757
     *
758
     * @method _saveCopiedNode
759
     * @private
760
     * @param {Object} data node position information
761
     * @returns {$.Deferred}
762
     */
763
    _saveCopiedNode(data) {
764
        var that = this;
×
765

766
        // send the real ajax request for copying the plugin
767
        return $.ajax({
×
768
            method: 'post',
769
            url: that.options.urls.copy.replace('{id}', data.id),
770
            data: data
771
        })
772
            .done(function(r) {
773
                if (r.status && r.status === 400) { // eslint-disable-line
×
774
                    that.showError(r.content);
×
775
                } else {
776
                    that._reloadHelper();
×
777
                }
778
            })
779
            .fail(function(error) {
780
                that.showError(error.statusText);
×
781
            });
782
    }
783

784
    /**
785
     * Returns element from any sub nodes.
786
     *
787
     * @method _getElement
788
     * @private
789
     * @param {jQuery} el jQuery node form where to search
790
     * @returns {String} jsTree node element id
791
     */
792
    _getNodeId(el) {
793
        var cls = el.closest('.jstree-grid-cell').attr('class');
2✔
794

795
        // if it's not a cell, assume it's the root node
796
        return cls ? cls.replace(/.*jsgrid_(.+?)_col.*/, '$1') : '#';
2✔
797
    }
798

799
    /**
800
     * Gets the new node position after moving.
801
     *
802
     * @method _getNodePosition
803
     * @private
804
     * @param {Object} obj jstree move object
805
     * @returns {Object} evaluated object with params
806
     */
807
    _getNodePosition(obj) {
808
        var data = {};
×
809
        var node = this.ui.tree.jstree('get_node', obj.node.parent);
×
810

811
        data.position = obj.position;
×
812

813
        // jstree indicates no parent with `#`, in this case we do not
814
        // need to set the target attribute at all
815
        if (obj.parent !== '#') {
×
816
            data.target = node.data.id;
×
817
        }
818

819
        // some functions like copy create a new element with a new id,
820
        // in this case we need to set `data.id` manually
821
        if (obj.node && obj.node.data) {
×
822
            data.id = obj.node.data.id;
×
823
        }
824

825
        return data;
×
826
    }
827

828
    /**
829
     * Sets up general tooltips that can have a list of links or content.
830
     *
831
     * @method _setupDropdowns
832
     * @private
833
     */
834
    _setupDropdowns() {
835
        this._dropdowns = new PageTreeDropdowns({
21✔
836
            container: this.ui.container
837
        });
838
    }
839

840
    /**
841
     * Handles page view click. Usual use case is that after you click
842
     * on view page in the pagetree - sideframe is no longer needed,
843
     * so we close it.
844
     *
845
     * @method _setupPageView
846
     * @private
847
     */
848
    _setupPageView() {
849
        var win = Helpers._getWindow();
25✔
850
        var parent = win.parent ? win.parent : win;
25✔
851

852
        this.ui.container.on(this.click, '.js-cms-pagetree-page-view', function() {
25✔
853
            parent.CMS.API.Helpers.setSettings(
3✔
854
                $.extend(true, {}, CMS.settings, {
855
                    sideframe: {
856
                        url: null,
857
                        hidden: true
858
                    }
859
                })
860
            );
861
        });
862
    }
863

864
    /**
865
     * @method _setupStickyHeader
866
     * @private
867
     */
868
    _setupStickyHeader() {
869
        var that = this;
21✔
870

871
        that.ui.tree.on('ready.jstree', function() {
21✔
872
            that.header = new PageTreeStickyHeader({
×
873
                container: that.ui.container
874
            });
875
        });
876
    }
877

878
    /**
879
     * Triggers the links `href` as ajax post request.
880
     *
881
     * @method _setAjaxPost
882
     * @private
883
     * @param {jQuery} trigger jQuery link target
884
     */
885
    _setAjaxPost(trigger) {
886
        var that = this;
63✔
887

888
        this.ui.container.on(this.click, trigger, function(e) {
63✔
889
            e.preventDefault();
×
890

891
            var element = $(this);
×
892

893
            if (element.closest('.cms-pagetree-dropdown-item-disabled').length) {
×
894
                return;
×
895
            }
896
            if (element.attr('target') === '_top') {
×
897
                // Post to target="_top" requires to create a form and submit it
898
                var parent = window;
×
899

900
                if (window.parent) {
×
901
                    parent = window.parent;
×
902
                }
903
                let formToken = document.querySelector('form input[name="csrfmiddlewaretoken"]');
×
NEW
904
                let csrfTokenValue = (formToken ? formToken.value : formToken) || window.CMS.config.csrf;
×
905

NEW
906
                let form = document.createElement('form');
×
NEW
907
                form.method = 'post';
×
NEW
908
                form.action = element.attr('href');
×
909

NEW
910
                let csrfInput = document.createElement('input');
×
NEW
911
                csrfInput.type = 'hidden';
×
NEW
912
                csrfInput.name = 'csrfmiddlewaretoken';
×
NEW
913
                csrfInput.value = csrfTokenValue;
×
NEW
914
                form.appendChild(csrfInput);
×
915

NEW
916
                parent.document.body.appendChild(form);
×
NEW
917
                form.submit();
×
UNCOV
918
                return;
×
919
            }
920
            try {
×
921
                window.top.CMS.API.Toolbar.showLoader();
×
922
            } catch {}
923

924
            $.ajax({
×
925
                method: 'post',
926
                url: $(this).attr('href')
927
            })
928
                .done(function() {
929
                    try {
×
930
                        window.top.CMS.API.Toolbar.hideLoader();
×
931
                    } catch {}
932

933
                    if (window.self === window.top) {
×
934
                        // simply reload the page
935
                        that._reloadHelper();
×
936
                    } else {
937
                        Helpers.reloadBrowser('REFRESH_PAGE');
×
938
                    }
939
                })
940
                .fail(function(error) {
941
                    try {
×
942
                        window.top.CMS.API.Toolbar.hideLoader();
×
943
                    } catch {}
944
                    that.showError(error.responseText ? error.responseText : error.statusText);
×
945
                });
946
        });
947
    }
948

949
    /**
950
     * Sets events for the search on the header.
951
     *
952
     * @method _setupSearch
953
     * @private
954
     */
955
    _setupSearch() {
956
        var that = this;
21✔
957
        var click = this.click + '.search';
21✔
958

959
        var filterActive = false;
21✔
960
        var filterTrigger = this.ui.container.find('.js-cms-pagetree-header-filter-trigger');
21✔
961
        var filterContainer = this.ui.container.find('.js-cms-pagetree-header-filter-container');
21✔
962
        var filterClose = filterContainer.find('.js-cms-pagetree-header-search-close');
21✔
963
        var filterClass = 'cms-pagetree-header-filter-active';
21✔
964
        var pageTreeHeader = $('.cms-pagetree-header');
21✔
965

966
        var visibleForm = this.ui.container.find('.js-cms-pagetree-header-search');
21✔
967
        var hiddenForm = this.ui.container.find('.js-cms-pagetree-header-search-copy form');
21✔
968

969
        var searchContainer = this.ui.container.find('.cms-pagetree-header-filter');
21✔
970
        var searchField = searchContainer.find('#field-searchbar');
21✔
971
        var timeout = 200;
21✔
972

973
        // add active class when focusing the search field
974
        searchField.on('focus', function(e) {
21✔
975
            e.stopImmediatePropagation();
×
976
            pageTreeHeader.addClass(filterClass);
×
977
        });
978
        searchField.on('blur', function(e) {
21✔
979
            e.stopImmediatePropagation();
×
980
            // timeout is required to prevent the search field from jumping
981
            // between enlarging and shrinking
982
            setTimeout(function() {
×
983
                if (!filterActive) {
×
984
                    pageTreeHeader.removeClass(filterClass);
×
985
                }
986
            }, timeout);
987
            that.ui.document.off(click);
×
988
        });
989

990
        // shows/hides filter box
991
        filterTrigger.add(filterClose).on(click, function(e) {
21✔
992
            e.preventDefault();
×
993
            e.stopImmediatePropagation();
×
994
            if (filterActive) {
×
995
                filterContainer.hide();
×
996
                pageTreeHeader.removeClass(filterClass);
×
997
                that.ui.document.off(click);
×
998
                filterActive = false;
×
999
            } else {
1000
                filterContainer.show();
×
1001
                pageTreeHeader.addClass(filterClass);
×
1002
                that.ui.document.on(click, function() {
×
1003
                    filterActive = true;
×
1004
                    filterTrigger.trigger(click);
×
1005
                });
1006
                filterActive = true;
×
1007
            }
1008
        });
1009

1010
        // prevent closing when on filter container
1011
        filterContainer.on('click', function(e) {
21✔
1012
            e.stopImmediatePropagation();
×
1013
        });
1014

1015
        // add hidden fields to the form to maintain filter params
1016
        visibleForm.append(hiddenForm.find('input[type="hidden"]'));
21✔
1017
    }
1018

1019
    /**
1020
     * Shows paste helpers.
1021
     *
1022
     * @method _enablePaste
1023
     * @param {String} [selector=this.options.pasteSelector] jquery selector
1024
     * @private
1025
     */
1026
    _enablePaste(selector) {
1027
        var sel = typeof selector === 'undefined'
2✔
1028
            ? this.options.pasteSelector
1029
            : selector + ' ' + this.options.pasteSelector;
1030
        var dropdownSel = '.js-cms-pagetree-actions-dropdown';
2✔
1031

1032
        if (typeof selector !== 'undefined') {
2✔
1033
            dropdownSel = selector + ' .js-cms-pagetree-actions-dropdown';
1✔
1034
        }
1035

1036
        // helpers are generated on the fly, so we need to reference
1037
        // them every single time
1038
        $(sel).removeClass('cms-pagetree-dropdown-item-disabled');
2✔
1039

1040
        var data = {};
2✔
1041

1042
        if (this.clipboard.type === 'cut') {
2!
1043
            data.has_cut = true;
×
1044
        } else {
1045
            data.has_copy = true;
2✔
1046
        }
1047
        // not loaded actions dropdown have to be updated as well
1048
        $(dropdownSel).data('lazyUrlData', data);
2✔
1049
    }
1050

1051
    /**
1052
     * Hides paste helpers.
1053
     *
1054
     * @method _disablePaste
1055
     * @param {String} [selector=this.options.pasteSelector] jquery selector
1056
     * @private
1057
     */
1058
    _disablePaste(selector) {
1059
        var sel = typeof selector === 'undefined'
2✔
1060
            ? this.options.pasteSelector
1061
            : selector + ' ' + this.options.pasteSelector;
1062
        var dropdownSel = '.js-cms-pagetree-actions-dropdown';
2✔
1063

1064
        if (typeof selector !== 'undefined') {
2✔
1065
            dropdownSel = selector + ' .js-cms-pagetree-actions-dropdown';
1✔
1066
        }
1067

1068
        // helpers are generated on the fly, so we need to reference
1069
        // them every single time
1070
        $(sel).addClass('cms-pagetree-dropdown-item-disabled');
2✔
1071

1072
        // not loaded actions dropdown have to be updated as well
1073
        $(dropdownSel).removeData('lazyUrlData');
2✔
1074
    }
1075

1076
    /**
1077
     * Updates the current state of the helpers after `after_open.jstree`
1078
     * or `_cutOrCopy` is triggered.
1079
     *
1080
     * @method _updatePasteHelpersState
1081
     * @private
1082
     */
1083
    _updatePasteHelpersState() {
1084
        var that = this;
4✔
1085

1086
        if (this.clipboard.type && this.clipboard.id) {
4✔
1087
            this._enablePaste();
3✔
1088
        }
1089

1090
        // hide cut element and it's descendants' paste helpers if it is visible
1091
        if (
4✔
1092
            this.clipboard.type === 'cut' &&
8✔
1093
            this.clipboard.origin &&
1094
            this.options.site === this.clipboard.source_site
1095
        ) {
1096
            var descendantIds = this._getDescendantsIds(this.clipboard.id);
2✔
1097
            var nodes = [this.clipboard.id];
2✔
1098

1099
            if (descendantIds && descendantIds.length) {
2✔
1100
                nodes = nodes.concat(descendantIds);
1✔
1101
            }
1102

1103
            nodes.forEach(function(id) {
2✔
1104
                that._disablePaste('.jsgrid_' + id + '_col');
4✔
1105
            });
1106
        }
1107
    }
1108

1109
    /**
1110
     * Shows success message on node after successful action.
1111
     *
1112
     * @method _showSuccess
1113
     * @param {Number} id id of the element to add the success class
1114
     * @private
1115
     */
1116
    _showSuccess(id) {
1117
        var element = this.ui.tree.find('li[data-id="' + id + '"]');
×
1118

1119
        element.addClass('cms-tree-node-success');
×
1120
        setTimeout(function() {
×
1121
            element.removeClass('cms-tree-node-success');
×
1122
        }, this.successTimer);
1123
        // hide elements
1124
        this._disablePaste();
×
1125
        this.clipboard.id = null;
×
1126
    }
1127

1128
    /**
1129
     * Checks if we should reload the iframe or entire window. For this we
1130
     * need to skip `CMS.API.Helpers.reloadBrowser();`.
1131
     *
1132
     * @method _reloadHelper
1133
     * @private
1134
     */
1135
    _reloadHelper() {
1136
        if (window.self === window.top) {
×
1137
            Helpers.reloadBrowser();
×
1138
        } else {
1139
            window.location.reload();
×
1140
        }
1141
    }
1142

1143
    /**
1144
     * Displays an error within the django UI.
1145
     *
1146
     * @method showError
1147
     * @param {String} message string message to display
1148
     */
1149
    showError(message) {
1150
        var messages = $('.messagelist');
×
1151
        var breadcrumb = $('.breadcrumbs');
×
1152
        var reload = this.options.lang.reload;
×
1153
        var tpl =
1154
            '' +
×
1155
            '<ul class="messagelist">' +
1156
            '   <li class="error">' +
1157
            '       {msg} ' +
1158
            '       <a href="#reload" class="cms-tree-reload"> ' +
1159
            reload +
1160
            ' </a>' +
1161
            '   </li>' +
1162
            '</ul>';
1163
        var msg = tpl.replace('{msg}', '<strong>' + this.options.lang.error + '</strong> ' + message);
×
1164

1165
        if (messages.length) {
×
1166
            messages.replaceWith(msg);
×
1167
        } else {
1168
            breadcrumb.after(msg);
×
1169
        }
1170
    }
1171

1172
    /**
1173
     * @method _getDescendantsIds
1174
     * @private
1175
     * @param {String} nodeId jstree id of the node, e.g. j1_3
1176
     * @returns {String[]} array of ids
1177
     */
1178
    _getDescendantsIds(nodeId) {
1179
        return this.ui.tree.jstree(true).get_node(nodeId).children_d;
2✔
1180
    }
1181

1182
    /**
1183
     * @method _hasPermision
1184
     * @private
1185
     * @param {Object} node jstree node
1186
     * @param {String} permission move / add
1187
     * @returns {Boolean}
1188
     */
1189
    _hasPermission(node, permission) {
1190
        if (node.id === '#' && permission === 'add') {
×
1191
            return this.options.hasAddRootPermission;
×
1192
        } else if (node.id === '#') {
×
1193
            return false;
×
1194
        }
1195

1196
        return node.li_attr['data-' + permission + '-permission'] === 'true';
×
1197
    }
1198

1199
    static _init() {
1200
        new PageTree();
1✔
1201
    }
1202
}
1203

1204
// shorthand for jQuery(document).ready();
1205
$(function() {
1✔
1206
    // load cms settings beforehand
1207
    // have to set toolbar to "expanded" by default
1208
    // otherwise initialization will be incorrect when you
1209
    // go first to pages and then to normal page
1210
    window.CMS.config = {
1✔
1211
        isPageTree: true,
1212
        settings: {
1213
            toolbar: 'expanded',
1214
            version: __CMS_VERSION__
1215
        },
1216
        urls: {
1217
            settings: $('.js-cms-pagetree').data('settings-url')
1218
        }
1219
    };
1220
    window.CMS.settings = Helpers.getSettings();
1✔
1221
    // autoload the pagetree
1222
    CMS.PageTree._init();
1✔
1223
});
1224

1225
// Define default options on the prototype for test compatibility
1226
PageTree.prototype.options = {
1✔
1227
    pasteSelector: '.js-cms-tree-item-paste'
1228
};
1229

1230
export default PageTree;
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