Completed
Push — master ( d80d19...4ebc78 )
by
unknown
18:05
created

web/static/js/application.js   F

Complexity

Total Complexity 77
Complexity/F 2.08

Size

Lines of Code 526
Function Count 37

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 1 Features 0
Metric Value
wmc 77
dl 0
loc 526
rs 3.8125
c 5
b 1
f 0
cc 0
nc 6144
mnd 2
bc 68
fnc 37
bpm 1.8378
cpm 2.081
noi 10

9 Functions

Rating   Name   Duplication   Size   Complexity  
A application.js ➔ setupNamespaceSelector 0 47 1
B window.setupToggleTable 0 35 1
B application.js ➔ setupTOC 0 77 3
D application.js ➔ setupAutocompletion 0 90 9
B application.js ➔ displayWaitingNoticeOnSubmission 0 30 2
B application.js ➔ setupProjectListener 0 29 6
B application.js ➔ setupColumnSorting 0 38 1
A application.js ➔ revertToValidProject 0 12 1
B $(document).ready 0 26 1

How to fix   Complexity   

Complexity

Complex classes like web/static/js/application.js often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
(function () {
2
    var sortDirection, sortColumn, $tocClone, tocHeight, sectionOffset = {},
3
        toggleTableData, apiPath, lastProject;
4
5
    // Load translations with 'en.json' as a fallback
6
    var messagesToLoad = {};
7
    messagesToLoad[i18nLang] = assetPath + 'static/i18n/' + i18nLang + '.json';
0 ignored issues
show
Bug introduced by
The variable assetPath seems to be never declared. If this is a global, consider adding a /** global: assetPath */ comment.

This checks looks for references to variables that have not been declared. This is most likey a typographical error or a variable has been renamed.

To learn more about declaring variables in Javascript, see the MDN.

Loading history...
Bug introduced by
The variable i18nLang seems to be never declared. If this is a global, consider adding a /** global: i18nLang */ comment.

This checks looks for references to variables that have not been declared. This is most likey a typographical error or a variable has been renamed.

To learn more about declaring variables in Javascript, see the MDN.

Loading history...
8
    if (i18nLang !== 'en') {
9
        messagesToLoad.en = assetPath + 'static/i18n/en.json';
10
    }
11
12
    $.i18n({
13
        locale: i18nLang
14
    }).load(messagesToLoad);
15
16
    $(document).ready(function () {
17
        $('.xt-hide').on('click', function () {
18
            $(this).hide();
19
            $(this).siblings('.xt-show').show();
20
            $(this).parents('.panel-heading').siblings('.panel-body').hide();
21
        });
22
        $('.xt-show').on('click', function () {
23
            $(this).hide();
24
            $(this).siblings('.xt-hide').show();
25
            $(this).parents('.panel-heading').siblings('.panel-body').show();
26
        });
27
28
        setupColumnSorting();
29
        setupTOC();
30
        setupProjectListener();
31
        setupAutocompletion();
32
        displayWaitingNoticeOnSubmission();
33
34
        // Re-init forms, workaround for issues with Safari and Firefox.
35
        // See displayWaitingNoticeOnSubmission() for more.
36
        window.onpageshow = function (e) {
37
            if (e.persisted) {
38
                displayWaitingNoticeOnSubmission(true);
39
            }
40
        };
41
    });
42
43
    /**
44
     * Script to make interactive toggle table and pie chart.
45
     * For visual example, see the "Semi-automated edits" section of the AutoEdits tool.
46
     *
47
     * Example usage (see autoEdits/result.html.twig and js/autoedits.js for more):
48
     *     <table class="table table-bordered table-hover table-striped toggle-table">
49
     *         <thead>...</thead>
50
     *         <tbody>
51
     *             {% for tool, values in semi_automated %}
52
     *             <tr>
53
     *                 <!-- use the 'linked' class here because the cell contains a link -->
54
     *                 <td class="sort-entry--tool linked" data-value="{{ tool }}">
55
     *                     <span class="toggle-table--toggle" data-index="{{ loop.index0 }}" data-key="{{ tool }}">
56
     *                         <span class="glyphicon glyphicon-remove"></span>
57
     *                         <span class="color-icon" style="background:{{ chartColor(loop.index0) }}"></span>
58
     *                     </span>
59
     *                     {{ wiki.pageLink(...) }}
60
     *                 </td>
61
     *                 <td class="sort-entry--count" data-value="{{ values.count }}">
62
     *                     {{ values.count }}
63
     *                 </td>
64
     *             </tr>
65
     *             {% endfor %}
66
     *             ...
67
     *         </tbody>
68
     *     </table>
69
     *     <div class="toggle-table--chart">
70
     *         <canvas id="tool_chart" width="400" height="400"></canvas>
71
     *     </div>
72
     *     <script>
73
     *         window.toolsChart = new Chart($('#tool_chart'), { ... });
74
     *         window.countsByTool = {{ semi_automated | json_encode() | raw }};
75
     *         ...
76
     *
77
     *         // See autoedits.js for more
78
     *         window.setupToggleTable(window.countsByTool, window.toolsChart, 'count', function (newData) {
79
     *             // update the totals in toggle table based on newData
80
     *         });
81
     *     </script>
82
     *
83
     * @param  {Object}   dataSource     Object of data that makes up the chart
84
     * @param  {Chart}    chartObj       Reference to the pie chart associated with the .toggle-table
85
     * @param  {String}   [valueKey]     The name of the key within entries of dataSource,
86
     *                                   where the value is what's shown in the chart.
87
     *                                   If omitted or null, `dataSource` is assumed to be of the structure:
88
     *                                   { 'a' => 123, 'b' => 456 }
89
     * @param  {Function} updateCallback Callback to update the .toggle-table totals. `toggleTableData`
90
     *                                   is passed in which contains the new data, you just need to
91
     *                                   format it (maybe need to use i18n, update multiple cells, etc.)
92
     */
93
    window.setupToggleTable = function (dataSource, chartObj, valueKey, updateCallback) {
94
        $('.toggle-table').on('click', '.toggle-table--toggle', function () {
95
            if (!toggleTableData) {
96
                // must be cloned
97
                toggleTableData = Object.assign({}, dataSource);
98
            }
99
100
            var index = $(this).data('index'),
101
                key = $(this).data('key'),
102
                $row = $(this).parents('tr');
0 ignored issues
show
Unused Code introduced by
The variable $row seems to be never used. Consider removing it.
Loading history...
103
104
            // must use .attr instead of .prop as sorting script will clone DOM elements
105
            if ($(this).attr('data-disabled') === 'true') {
106
                toggleTableData[key] = dataSource[key];
107
                var oldValue = parseInt(valueKey ? toggleTableData[key][valueKey] : toggleTableData[key], 10);
108
                chartObj.data.datasets[0].data[index] = oldValue;
109
                $(this).attr('data-disabled', 'false');
110
            } else {
111
                delete toggleTableData[key];
112
                chartObj.data.datasets[0].data[index] = null;
113
                $(this).attr('data-disabled', 'true');
114
            }
115
116
            // gray out row in table
117
            $(this).parents('tr').toggleClass('excluded');
118
119
            // change the hover icon from a 'x' to a '+'
120
            $(this).find('.glyphicon').toggleClass('glyphicon-remove').toggleClass('glyphicon-plus');
121
122
            // update stats
123
            updateCallback(toggleTableData);
124
125
            chartObj.update();
126
        });
127
    }
128
129
    /**
130
     * Sorting of columns
131
     *
132
     *  Example usage:
133
     *   {% for key in ['username', 'edits', 'minor', 'date'] %}
134
     *      <th>
135
     *         <span class="sort-link sort-link--{{ key }}" data-column="{{ key }}">
136
     *            {{ msg(key) | capitalize }}
137
     *            <span class="glyphicon glyphicon-sort"></span>
138
     *         </span>
139
     *      </th>
140
     *  {% endfor %}
141
     *   <th class="sort-link" data-column="username">Username</th>
142
     *   ...
143
     *   <td class="sort-entry--username" data-value="{{ username }}">{{ username }}</td>
144
     *   ...
145
     *
146
     * Data type is automatically determined, with support for integer,
147
     *   floats, and strings, including date strings (e.g. "2016-01-01 12:59")
148
     */
149
    function setupColumnSorting()
150
    {
151
        $('.sort-link').on('click', function () {
152
            sortDirection = sortColumn === $(this).data('column') ? -sortDirection : 1;
153
154
            $('.sort-link .glyphicon').removeClass('glyphicon-sort-by-alphabet-alt glyphicon-sort-by-alphabet').addClass('glyphicon-sort');
155
            var newSortClassName = sortDirection === 1 ? 'glyphicon-sort-by-alphabet-alt' : 'glyphicon-sort-by-alphabet';
156
            $(this).find('.glyphicon').addClass(newSortClassName).removeClass('glyphicon-sort');
157
158
            sortColumn = $(this).data('column');
159
            var $table = $(this).parents('table');
160
            var entries = $table.find('.sort-entry--' + sortColumn).parent();
161
162
            if (!entries.length) {
163
                return; }
164
165
            entries.sort(function (a, b) {
166
                var before = $(a).find('.sort-entry--' + sortColumn).data('value'),
167
                after = $(b).find('.sort-entry--' + sortColumn).data('value');
168
169
                // test data type, assumed to be string if can't be parsed as float
170
                if (!isNaN(parseFloat(before, 10))) {
171
                    before = parseFloat(before, 10);
172
                    after = parseFloat(after, 10);
173
                }
174
175
                if (before < after) {
176
                    return sortDirection;
177
                } else if (before > after) {
178
                    return -sortDirection;
179
                } else {
180
                    return 0;
181
                }
182
            });
183
184
            $table.find('tbody').html($(entries));
185
        });
186
    }
187
188
    /**
189
     * Floating table of contents
190
     *
191
     * Example usage (see articleInfo/result.html.twig for more):
192
     *     <p class="text-center xt-heading-subtitle">
193
     *         ...
194
     *     </p>
195
     *     <div class="text-center xt-toc">
196
     *         {% set sections = ['generalstats', 'usertable', 'yearcounts', 'monthcounts'] %}
197
     *         {% for section in sections %}
198
     *             <span>
199
     *                 <a href="#{{ section }}" data-section="{{ section }}">{{ msg(section) }}</a>
200
     *             </span>
201
     *         {% endfor %}
202
     *     </div>
203
     *     ...
204
     *     {% set content %}
205
     *         ...content for general stats...
206
     *     {% endset %}
207
     *     {{ layout.content_block('generalstats', content) }}
208
     *     ...
209
     */
210
    function setupTOC()
211
    {
212
        var $toc = $('.xt-toc');
213
214
        if (!$toc || !$toc[0]) {
215
            return;
216
        }
217
218
        tocHeight = $toc.height();
219
220
        // listeners on the section links
221
        var setupTocListeners = function () {
222
            $('.xt-toc').find('a').off('click').on('click', function (e) {
223
                document.activeElement.blur();
224
                var $newSection = $('#' + $(e.target).data('section'));
225
                $(window).scrollTop($newSection.offset().top - tocHeight);
226
227
                $(this).parents('.xt-toc').find('a').removeClass('bold');
228
229
                createTocClone();
230
                $tocClone.addClass('bold');
231
            });
232
        };
233
234
        // clone the TOC and add position:fixed
235
        var createTocClone = function () {
236
            if ($tocClone) {
237
                return;
238
            }
239
            $tocClone = $toc.clone();
240
            $tocClone.addClass('fixed');
241
            $toc.after($tocClone);
242
            setupTocListeners();
243
        };
244
245
        // build object containing offsets of each section
246
        var buildSectionOffsets = function () {
247
            $.each($toc.find('a'), function (index, tocMember) {
248
                var id = $(tocMember).data('section');
249
                sectionOffset[id] = $('#' + id).offset().top;
250
            });
251
        }
252
253
        // rebuild section offsets when sections are shown/hidden
254
        $('.xt-show, .xt-hide').on('click', buildSectionOffsets);
255
256
        buildSectionOffsets();
257
        setupTocListeners();
258
259
        var tocOffsetTop = $toc.offset().top;
260
        $(window).on('scroll', function (e) {
261
            var windowOffset = $(e.target).scrollTop();
262
            var inRange = windowOffset > tocOffsetTop;
263
264
            if (inRange) {
265
                if (!$tocClone) {
266
                    createTocClone();
267
                }
268
269
                // bolden the link for whichever section we're in
270
                var $activeMember;
271
                Object.keys(sectionOffset).forEach(function (section) {
272
                    if (windowOffset > sectionOffset[section] - tocHeight - 1) {
273
                        $activeMember = $tocClone.find('a[data-section="' + section + '"]');
0 ignored issues
show
Bug introduced by
The variable $tocClone seems to not be initialized for all possible execution paths.
Loading history...
274
                    }
275
                });
276
                $tocClone.find('a').removeClass('bold');
277
                if ($activeMember) {
278
                    $activeMember.addClass('bold');
279
                }
280
            } else if (!inRange && $tocClone) {
281
                // remove the clone once we're out of range
282
                $tocClone.remove();
283
                $tocClone = null;
284
            }
285
        });
286
    }
287
288
    /**
289
     * Add listener to the project input field to update any
290
     * namespace selectors and autocompletion fields.
291
     */
292
    function setupProjectListener()
293
    {
294
        // Stop here if there is no project field
295
        if (!$("#project_input")) {
296
            return;
297
        }
298
299
        // If applicable, setup namespace selector with real time updates when changing projects.
300
        // This will also set `apiPath` so that autocompletion will query the right wiki.
301
        if ($('#project_input').length && $('#namespace_select').length) {
302
            setupNamespaceSelector();
303
        // Otherwise, if there's a user or page input field, we still need to update `apiPath`
304
        // for the user input autocompletion when the project is changed.
305
        } else if ($('#user_input')[0] || $('#article_input')[0]) {
306
            // keep track of last valid project
307
            lastProject = $('#project_input').val();
308
309
            $('#project_input').on('change', function () {
310
                var newProject = this.value;
311
312
                $.get(xtBaseUrl + 'api/normalizeProject/' + newProject).done(function (data) {
0 ignored issues
show
Bug introduced by
The variable xtBaseUrl seems to be never declared. If this is a global, consider adding a /** global: xtBaseUrl */ comment.

This checks looks for references to variables that have not been declared. This is most likey a typographical error or a variable has been renamed.

To learn more about declaring variables in Javascript, see the MDN.

Loading history...
313
                    // Keep track of project API path for use in page title autocompletion
314
                    apiPath = data.api;
315
                    lastProject = newProject;
316
                    setupAutocompletion();
317
                }).fail(revertToValidProject.bind(this, newProject));
0 ignored issues
show
Unused Code introduced by
The call to bind does not seem necessary since the function revertToValidProject declared on line 381 does not use this.
Loading history...
318
            });
319
        }
320
    }
321
322
    /**
323
     * Use the wiki input field to populate the namespace selector.
324
     * This also updates `apiPath` and calls setupAutocompletion()
325
     */
326
    function setupNamespaceSelector()
327
    {
328
        // keep track of last valid project
329
        lastProject = $('#project_input').val();
330
331
        $('#project_input').on('change', function () {
332
            // Disable the namespace selector and show a spinner while the data loads.
333
            $('#namespace_select').prop('disabled', true);
334
            $loader = $('span.loader');
0 ignored issues
show
Bug introduced by
The variable $loader seems to be never declared. Assigning variables without defining them first makes them global. If this was intended, consider making it explicit like using window.$loader.
Loading history...
335
            $('label[for="namespace_select"]').append($loader);
336
            $loader.removeClass('hidden');
337
338
            var newProject = this.value;
339
340
            $.get(xtBaseUrl + 'api/namespaces/' + newProject).done(function (data) {
0 ignored issues
show
Bug introduced by
The variable xtBaseUrl seems to be never declared. If this is a global, consider adding a /** global: xtBaseUrl */ comment.

This checks looks for references to variables that have not been declared. This is most likey a typographical error or a variable has been renamed.

To learn more about declaring variables in Javascript, see the MDN.

Loading history...
341
                // Clone the 'all' option (even if there isn't one),
342
                // and replace the current option list with this.
343
                var $allOption = $('#namespace_select option[value="all"]').eq(0).clone();
344
                $("#namespace_select").html($allOption);
345
346
                // Keep track of project API path for use in page title autocompletion
347
                apiPath = data.api;
348
349
                // Add all of the new namespace options.
350
                for (var ns in data.namespaces) {
0 ignored issues
show
Complexity introduced by
A for in loop automatically includes the property of any prototype object, consider checking the key using hasOwnProperty.

When iterating over the keys of an object, this includes not only the keys of the object, but also keys contained in the prototype of that object. It is generally a best practice to check for these keys specifically:

var someObject;
for (var key in someObject) {
    if ( ! someObject.hasOwnProperty(key)) {
        continue; // Skip keys from the prototype.
    }

    doSomethingWith(key);
}
Loading history...
351
                    var nsName = parseInt(ns, 10) === 0 ? $.i18n('mainspace') : data.namespaces[ns];
352
                    $('#namespace_select').append(
353
                        "<option value=" + ns + ">" + nsName + "</option>"
354
                    );
355
                }
356
                // Default to mainspace being selected.
357
                $("#namespace_select").val(0);
358
                lastProject = newProject;
359
360
                // Re-init autocompletion
361
                setupAutocompletion();
362
            }).fail(revertToValidProject.bind(this, newProject)).always(function () {
0 ignored issues
show
Unused Code introduced by
The call to bind does not seem necessary since the function revertToValidProject declared on line 381 does not use this.
Loading history...
363
                $('#namespace_select').prop('disabled', false);
364
                $loader.addClass('hidden');
365
            });
366
        });
367
368
        // If they change the namespace, update autocompletion,
369
        // which will ensure only pages in the selected namespace
370
        // show up in the autocompletion
371
        $('#namespace_select').on('change', setupAutocompletion);
372
    }
373
374
    /**
375
     * Called by setupNamespaceSelector or setupProjectListener
376
     *   when the user changes to a project that doesn't exist.
377
     * This throws a warning message and reverts back to the
378
     *   last valid project.
379
     * @param {string} newProject - project they attempted to add
380
     */
381
    function revertToValidProject(newProject)
382
    {
383
        $('#project_input').val(lastProject);
384
        $('.site-notice').append(
385
            "<div class='alert alert-warning alert-dismissible' role='alert'>" +
386
                $.i18n('invalid-project', "<strong>" + newProject + "</strong>") +
387
                "<button class='close' data-dismiss='alert' aria-label='Close'>" +
388
                    "<span aria-hidden='true'>&times;</span>" +
389
                "</button>" +
390
            "</div>"
391
        );
392
    }
393
394
    /**
395
     * Setup autocompletion of pages if a page input field is present.
396
     */
397
    function setupAutocompletion()
398
    {
399
        var $articleInput = $('#article_input'),
400
            $userInput = $('#user_input'),
401
            $namespaceInput = $("#namespace_select");
402
403
        // Make sure typeahead-compatible fields are present
404
        if (!$articleInput[0] && !$userInput[0] && !$('#project_input')[0]) {
405
            return;
406
        }
407
408
        // Destroy any existing instances
409
        if ($articleInput.data('typeahead')) {
410
            $articleInput.data('typeahead').destroy();
411
        }
412
        if ($userInput.data('typeahead')) {
413
            $userInput.data('typeahead').destroy();
414
        }
415
416
        // set initial value for the API url, which is put as a data attribute in forms.html.twig
417
        if (!apiPath) {
418
            apiPath = $('#article_input').data('api') || $('#user_input').data('api');
419
        }
420
421
        // Defaults for typeahead options. preDispatch and preProcess will be
422
        // set accordingly for each typeahead instance
423
        var typeaheadOpts = {
424
            url: apiPath,
425
            timeout: 200,
426
            triggerLength: 1,
427
            method: 'get',
428
            preDispatch: null,
429
            preProcess: null,
430
        };
431
432
        if ($articleInput[0]) {
433
            $articleInput.typeahead({
434
                ajax: Object.assign(typeaheadOpts, {
435
                    preDispatch: function (query) {
436
                        // If there is a namespace selector, make sure we search
437
                        // only within that namespace
438
                        if ($namespaceInput[0] && $namespaceInput.val() !== '0') {
439
                            var nsName = $namespaceInput.find('option:selected').text().trim();
440
                            query = nsName + ':' + query;
441
                        }
442
                        return {
443
                            action: 'query',
444
                            list: 'prefixsearch',
445
                            format: 'json',
446
                            pssearch: query
447
                        };
448
                    },
449
                    preProcess: function (data) {
450
                        var nsName = '';
451
                        // Strip out namespace name if applicable
452
                        if ($namespaceInput[0] && $namespaceInput.val() !== '0') {
453
                            nsName = $namespaceInput.find('option:selected').text().trim();
454
                        }
455
                        return data.query.prefixsearch.map(function (elem) {
456
                            return elem.title.replace(new RegExp('^' + nsName + ':'), '');
457
                        });
458
                    },
459
                })
460
            });
461
        }
462
463
        if ($userInput[0]) {
464
            $userInput.typeahead({
465
                ajax: Object.assign(typeaheadOpts, {
466
                    preDispatch: function (query) {
467
                        return {
468
                            action: 'query',
469
                            list: 'prefixsearch',
470
                            format: 'json',
471
                            pssearch: 'User:' + query
472
                        };
473
                    },
474
                    preProcess: function (data) {
475
                        var results = data.query.prefixsearch.map(function (elem) {
476
                            return elem.title.split('/')[0].substr(elem.title.indexOf(':') + 1);
477
                        });
478
479
                        return results.filter(function (value, index, array) {
480
                            return array.indexOf(value) === index;
481
                        });
482
                    },
483
                })
484
            });
485
        }
486
    }
487
488
    /**
489
     * For any form submission, this disables the submit button and replaces its text with
490
     * a loading message and a counting timer.
491
     * @param {boolean} [undo] Revert the form back to the initial state.
492
     *                         This is used on page load to solve an issue with Safari and Firefox
493
     *                         where after browsing back to the form, the "loading" state persists.
494
     */
495
    function displayWaitingNoticeOnSubmission(undo)
496
    {
497
        if (undo) {
498
            // Re-enable form
499
            $('.form-control').prop('readonly', false);
500
            $('.form-submit').prop('disabled', false);
501
            $('.form-submit').text($.i18n('submit')).prop('disabled', false);
502
        } else {
503
            $('#content form').on('submit', function () {
504
                // Remove focus from any active element
505
                document.activeElement.blur();
506
507
                // Disable the form so they can't hit Enter to re-submit
508
                $('.form-control').prop('readonly', true);
509
510
                // Change the submit button text.
511
                $('.form-submit').prop('disabled', true)
512
                    .html($.i18n('loading') + " <span id='submit_timer'></span>");
513
514
                // Add the counter.
515
                var startTime = Date.now();
516
                setInterval(function () {
517
                    var elapsedSeconds = Math.round((Date.now() - startTime) / 1000);
518
                    var minutes = Math.floor(elapsedSeconds / 60);
519
                    var seconds = ('00' + (elapsedSeconds - (minutes * 60))).slice(-2);
520
                    $('#submit_timer').text(minutes + ":" + seconds);
521
                }, 1000);
522
            });
523
        }
524
    }
525
526
})();
527