Completed
Push — master ( 1cfc3c...d2bafa )
by Sam
02:57
created

web/static/js/application.js   F

Complexity

Total Complexity 74
Complexity/F 2.06

Size

Lines of Code 503
Function Count 36

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 1 Features 0
Metric Value
cc 0
wmc 74
c 3
b 1
f 0
nc 6144
mnd 2
bc 64
fnc 36
dl 0
loc 503
rs 3.931
bpm 1.7777
cpm 2.0555
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
A application.js ➔ displayWaitingNoticeOnSubmission 0 19 1
B application.js ➔ setupProjectListener 0 29 6
B application.js ➔ setupColumnSorting 0 38 1
A application.js ➔ revertToValidProject 0 12 1
A $(document).ready 0 18 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
    $.i18n({
12
        locale: i18nLang
13
    }).load(messagesToLoad);
14
15
    $(document).ready(function () {
16
        $('.xt-hide').on('click', function () {
17
            $(this).hide();
18
            $(this).siblings('.xt-show').show();
19
            $(this).parents('.panel-heading').siblings('.panel-body').hide();
20
        });
21
        $('.xt-show').on('click', function () {
22
            $(this).hide();
23
            $(this).siblings('.xt-hide').show();
24
            $(this).parents('.panel-heading').siblings('.panel-body').show();
25
        });
26
27
        setupColumnSorting();
28
        setupTOC();
29
        setupProjectListener();
30
        setupAutocompletion();
31
        displayWaitingNoticeOnSubmission();
32
    });
33
34
    /**
35
     * Script to make interactive toggle table and pie chart.
36
     * For visual example, see the "Semi-automated edits" section of the AutoEdits tool.
37
     *
38
     * Example usage (see autoEdits/result.html.twig and js/autoedits.js for more):
39
     *     <table class="table table-bordered table-hover table-striped toggle-table">
40
     *         <thead>...</thead>
41
     *         <tbody>
42
     *             {% for tool, values in semi_automated %}
43
     *             <tr>
44
     *                 <!-- use the 'linked' class here because the cell contains a link -->
45
     *                 <td class="sort-entry--tool linked" data-value="{{ tool }}">
46
     *                     <span class="toggle-table--toggle" data-index="{{ loop.index0 }}" data-key="{{ tool }}">
47
     *                         <span class="glyphicon glyphicon-remove"></span>
48
     *                         <span class="color-icon" style="background:{{ chartColor(loop.index0) }}"></span>
49
     *                     </span>
50
     *                     {{ wiki.pageLink(...) }}
51
     *                 </td>
52
     *                 <td class="sort-entry--count" data-value="{{ values.count }}">
53
     *                     {{ values.count }}
54
     *                 </td>
55
     *             </tr>
56
     *             {% endfor %}
57
     *             ...
58
     *         </tbody>
59
     *     </table>
60
     *     <div class="toggle-table--chart">
61
     *         <canvas id="tool_chart" width="400" height="400"></canvas>
62
     *     </div>
63
     *     <script>
64
     *         window.toolsChart = new Chart($('#tool_chart'), { ... });
65
     *         window.countsByTool = {{ semi_automated | json_encode() | raw }};
66
     *         ...
67
     *
68
     *         // See autoedits.js for more
69
     *         window.setupToggleTable(window.countsByTool, window.toolsChart, 'count', function (newData) {
70
     *             // update the totals in toggle table based on newData
71
     *         });
72
     *     </script>
73
     *
74
     * @param  {Object}   dataSource     Object of data that makes up the chart
75
     * @param  {Chart}    chartObj       Reference to the pie chart associated with the .toggle-table
76
     * @param  {String}   [valueKey]     The name of the key within entries of dataSource,
77
     *                                   where the value is what's shown in the chart.
78
     *                                   If omitted or null, `dataSource` is assumed to be of the structure:
79
     *                                   { 'a' => 123, 'b' => 456 }
80
     * @param  {Function} updateCallback Callback to update the .toggle-table totals. `toggleTableData`
81
     *                                   is passed in which contains the new data, you just need to
82
     *                                   format it (maybe need to use i18n, update multiple cells, etc.)
83
     */
84
    window.setupToggleTable = function (dataSource, chartObj, valueKey, updateCallback) {
85
        $('.toggle-table').on('click', '.toggle-table--toggle', function () {
86
            if (!toggleTableData) {
87
                // must be cloned
88
                toggleTableData = Object.assign({}, dataSource);
89
            }
90
91
            var index = $(this).data('index'),
92
                key = $(this).data('key'),
93
                $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...
94
95
            // must use .attr instead of .prop as sorting script will clone DOM elements
96
            if ($(this).attr('data-disabled') === 'true') {
97
                toggleTableData[key] = dataSource[key];
98
                var oldValue = parseInt(valueKey ? toggleTableData[key][valueKey] : toggleTableData[key], 10);
99
                chartObj.data.datasets[0].data[index] = oldValue;
100
                $(this).attr('data-disabled', 'false');
101
            } else {
102
                delete toggleTableData[key];
103
                chartObj.data.datasets[0].data[index] = null;
104
                $(this).attr('data-disabled', 'true');
105
            }
106
107
            // gray out row in table
108
            $(this).parents('tr').toggleClass('excluded');
109
110
            // change the hover icon from a 'x' to a '+'
111
            $(this).find('.glyphicon').toggleClass('glyphicon-remove').toggleClass('glyphicon-plus');
112
113
            // update stats
114
            updateCallback(toggleTableData);
115
116
            chartObj.update();
117
        });
118
    }
119
120
    /**
121
     * Sorting of columns
122
     *
123
     *  Example usage:
124
     *   {% for key in ['username', 'edits', 'minor', 'date'] %}
125
     *      <th>
126
     *         <span class="sort-link sort-link--{{ key }}" data-column="{{ key }}">
127
     *            {{ msg(key) | capitalize }}
128
     *            <span class="glyphicon glyphicon-sort"></span>
129
     *         </span>
130
     *      </th>
131
     *  {% endfor %}
132
     *   <th class="sort-link" data-column="username">Username</th>
133
     *   ...
134
     *   <td class="sort-entry--username" data-value="{{ username }}">{{ username }}</td>
135
     *   ...
136
     *
137
     * Data type is automatically determined, with support for integer,
138
     *   floats, and strings, including date strings (e.g. "2016-01-01 12:59")
139
     */
140
    function setupColumnSorting()
141
    {
142
        $('.sort-link').on('click', function () {
143
            sortDirection = sortColumn === $(this).data('column') ? -sortDirection : 1;
144
145
            $('.sort-link .glyphicon').removeClass('glyphicon-sort-by-alphabet-alt glyphicon-sort-by-alphabet').addClass('glyphicon-sort');
146
            var newSortClassName = sortDirection === 1 ? 'glyphicon-sort-by-alphabet-alt' : 'glyphicon-sort-by-alphabet';
147
            $(this).find('.glyphicon').addClass(newSortClassName).removeClass('glyphicon-sort');
148
149
            sortColumn = $(this).data('column');
150
            var $table = $(this).parents('table');
151
            var entries = $table.find('.sort-entry--' + sortColumn).parent();
152
153
            if (!entries.length) {
154
                return; }
155
156
            entries.sort(function (a, b) {
157
                var before = $(a).find('.sort-entry--' + sortColumn).data('value'),
158
                after = $(b).find('.sort-entry--' + sortColumn).data('value');
159
160
                // test data type, assumed to be string if can't be parsed as float
161
                if (!isNaN(parseFloat(before, 10))) {
162
                    before = parseFloat(before, 10);
163
                    after = parseFloat(after, 10);
164
                }
165
166
                if (before < after) {
167
                    return sortDirection;
168
                } else if (before > after) {
169
                    return -sortDirection;
170
                } else {
171
                    return 0;
172
                }
173
            });
174
175
            $table.find('tbody').html($(entries));
176
        });
177
    }
178
179
    /**
180
     * Floating table of contents
181
     *
182
     * Example usage (see articleInfo/result.html.twig for more):
183
     *     <p class="text-center xt-heading-subtitle">
184
     *         ...
185
     *     </p>
186
     *     <div class="text-center xt-toc">
187
     *         {% set sections = ['generalstats', 'usertable', 'yearcounts', 'monthcounts'] %}
188
     *         {% for section in sections %}
189
     *             <span>
190
     *                 <a href="#{{ section }}" data-section="{{ section }}">{{ msg(section) }}</a>
191
     *             </span>
192
     *         {% endfor %}
193
     *     </div>
194
     *     ...
195
     *     {% set content %}
196
     *         ...content for general stats...
197
     *     {% endset %}
198
     *     {{ layout.content_block('generalstats', content) }}
199
     *     ...
200
     */
201
    function setupTOC()
202
    {
203
        var $toc = $('.xt-toc');
204
205
        if (!$toc || !$toc[0]) {
206
            return;
207
        }
208
209
        tocHeight = $toc.height();
210
211
        // listeners on the section links
212
        var setupTocListeners = function () {
213
            $('.xt-toc').find('a').off('click').on('click', function (e) {
214
                document.activeElement.blur();
215
                var $newSection = $('#' + $(e.target).data('section'));
216
                $(window).scrollTop($newSection.offset().top - tocHeight);
217
218
                $(this).parents('.xt-toc').find('a').removeClass('bold');
219
220
                createTocClone();
221
                $tocClone.addClass('bold');
222
            });
223
        };
224
225
        // clone the TOC and add position:fixed
226
        var createTocClone = function () {
227
            if ($tocClone) {
228
                return;
229
            }
230
            $tocClone = $toc.clone();
231
            $tocClone.addClass('fixed');
232
            $toc.after($tocClone);
233
            setupTocListeners();
234
        };
235
236
        // build object containing offsets of each section
237
        var buildSectionOffsets = function () {
238
            $.each($toc.find('a'), function (index, tocMember) {
239
                var id = $(tocMember).data('section');
240
                sectionOffset[id] = $('#' + id).offset().top;
241
            });
242
        }
243
244
        // rebuild section offsets when sections are shown/hidden
245
        $('.xt-show, .xt-hide').on('click', buildSectionOffsets);
246
247
        buildSectionOffsets();
248
        setupTocListeners();
249
250
        var tocOffsetTop = $toc.offset().top;
251
        $(window).on('scroll', function (e) {
252
            var windowOffset = $(e.target).scrollTop();
253
            var inRange = windowOffset > tocOffsetTop;
254
255
            if (inRange) {
256
                if (!$tocClone) {
257
                    createTocClone();
258
                }
259
260
                // bolden the link for whichever section we're in
261
                var $activeMember;
262
                Object.keys(sectionOffset).forEach(function (section) {
263
                    if (windowOffset > sectionOffset[section] - tocHeight - 1) {
264
                        $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...
265
                    }
266
                });
267
                $tocClone.find('a').removeClass('bold');
268
                if ($activeMember) {
269
                    $activeMember.addClass('bold');
270
                }
271
            } else if (!inRange && $tocClone) {
272
                // remove the clone once we're out of range
273
                $tocClone.remove();
274
                $tocClone = null;
275
            }
276
        });
277
    }
278
279
    /**
280
     * Add listener to the project input field to update any
281
     * namespace selectors and autocompletion fields.
282
     */
283
    function setupProjectListener()
284
    {
285
        // Stop here if there is no project field
286
        if (!$("#project_input")) {
287
            return;
288
        }
289
290
        // If applicable, setup namespace selector with real time updates when changing projects.
291
        // This will also set `apiPath` so that autocompletion will query the right wiki.
292
        if ($('#project_input').length && $('#namespace_select').length) {
293
            setupNamespaceSelector();
294
        // Otherwise, if there's a user or page input field, we still need to update `apiPath`
295
        // for the user input autocompletion when the project is changed.
296
        } else if ($('#user_input')[0] || $('#article_input')[0]) {
297
            // keep track of last valid project
298
            lastProject = $('#project_input').val();
299
300
            $('#project_input').on('change', function () {
301
                var newProject = this.value;
302
303
                $.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...
304
                    // Keep track of project API path for use in page title autocompletion
305
                    apiPath = data.api;
306
                    lastProject = newProject;
307
                    setupAutocompletion();
308
                }).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 372 does not use this.
Loading history...
309
            });
310
        }
311
    }
312
313
    /**
314
     * Use the wiki input field to populate the namespace selector.
315
     * This also updates `apiPath` and calls setupAutocompletion()
316
     */
317
    function setupNamespaceSelector()
318
    {
319
        // keep track of last valid project
320
        lastProject = $('#project_input').val();
321
322
        $('#project_input').on('change', function () {
323
            // Disable the namespace selector and show a spinner while the data loads.
324
            $('#namespace_select').prop('disabled', true);
325
            $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...
326
            $('label[for="namespace_select"]').append($loader);
327
            $loader.removeClass('hidden');
328
329
            var newProject = this.value;
330
331
            $.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...
332
                // Clone the 'all' option (even if there isn't one),
333
                // and replace the current option list with this.
334
                var $allOption = $('#namespace_select option[value="all"]').eq(0).clone();
335
                $("#namespace_select").html($allOption);
336
337
                // Keep track of project API path for use in page title autocompletion
338
                apiPath = data.api;
339
340
                // Add all of the new namespace options.
341
                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...
342
                    var nsName = parseInt(ns, 10) === 0 ? $.i18n('mainspace') : data.namespaces[ns];
343
                    $('#namespace_select').append(
344
                        "<option value=" + ns + ">" + nsName + "</option>"
345
                    );
346
                }
347
                // Default to mainspace being selected.
348
                $("#namespace_select").val(0);
349
                lastProject = newProject;
350
351
                // Re-init autocompletion
352
                setupAutocompletion();
353
            }).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 372 does not use this.
Loading history...
354
                $('#namespace_select').prop('disabled', false);
355
                $loader.addClass('hidden');
356
            });
357
        });
358
359
        // If they change the namespace, update autocompletion,
360
        // which will ensure only pages in the selected namespace
361
        // show up in the autocompletion
362
        $('#namespace_select').on('change', setupAutocompletion);
363
    }
364
365
    /**
366
     * Called by setupNamespaceSelector or setupProjectListener
367
     *   when the user changes to a project that doesn't exist.
368
     * This throws a warning message and reverts back to the
369
     *   last valid project.
370
     * @param {string} newProject - project they attempted to add
371
     */
372
    function revertToValidProject(newProject)
373
    {
374
        $('#project_input').val(lastProject);
375
        $('.site-notice').append(
376
            "<div class='alert alert-warning alert-dismissible' role='alert'>" +
377
                $.i18n('invalid-project', "<strong>" + newProject + "</strong>") +
378
                "<button class='close' data-dismiss='alert' aria-label='Close'>" +
379
                    "<span aria-hidden='true'>&times;</span>" +
380
                "</button>" +
381
            "</div>"
382
        );
383
    }
384
385
    /**
386
     * Setup autocompletion of pages if a page input field is present.
387
     */
388
    function setupAutocompletion()
389
    {
390
        var $articleInput = $('#article_input'),
391
            $userInput = $('#user_input'),
392
            $namespaceInput = $("#namespace_select");
393
394
        // Make sure typeahead-compatible fields are present
395
        if (!$articleInput[0] && !$userInput[0] && !$('#project_input')[0]) {
396
            return;
397
        }
398
399
        // Destroy any existing instances
400
        if ($articleInput.data('typeahead')) {
401
            $articleInput.data('typeahead').destroy();
402
        }
403
        if ($userInput.data('typeahead')) {
404
            $userInput.data('typeahead').destroy();
405
        }
406
407
        // set initial value for the API url, which is put as a data attribute in forms.html.twig
408
        if (!apiPath) {
409
            apiPath = $('#article_input').data('api') || $('#user_input').data('api');
410
        }
411
412
        // Defaults for typeahead options. preDispatch and preProcess will be
413
        // set accordingly for each typeahead instance
414
        var typeaheadOpts = {
415
            url: apiPath,
416
            timeout: 200,
417
            triggerLength: 1,
418
            method: 'get',
419
            preDispatch: null,
420
            preProcess: null,
421
        };
422
423
        if ($articleInput[0]) {
424
            $articleInput.typeahead({
425
                ajax: Object.assign(typeaheadOpts, {
426
                    preDispatch: function (query) {
427
                        // If there is a namespace selector, make sure we search
428
                        // only within that namespace
429
                        if ($namespaceInput[0] && $namespaceInput.val() !== '0') {
430
                            var nsName = $namespaceInput.find('option:selected').text().trim();
431
                            query = nsName + ':' + query;
432
                        }
433
                        return {
434
                            action: 'query',
435
                            list: 'prefixsearch',
436
                            format: 'json',
437
                            pssearch: query
438
                        };
439
                    },
440
                    preProcess: function (data) {
441
                        var nsName = '';
442
                        // Strip out namespace name if applicable
443
                        if ($namespaceInput[0] && $namespaceInput.val() !== '0') {
444
                            nsName = $namespaceInput.find('option:selected').text().trim();
445
                        }
446
                        return data.query.prefixsearch.map(function (elem) {
447
                            return elem.title.replace(new RegExp('^' + nsName + ':'), '');
448
                        });
449
                    },
450
                })
451
            });
452
        }
453
454
        if ($userInput[0]) {
455
            $userInput.typeahead({
456
                ajax: Object.assign(typeaheadOpts, {
457
                    preDispatch: function (query) {
458
                        return {
459
                            action: 'query',
460
                            list: 'prefixsearch',
461
                            format: 'json',
462
                            pssearch: 'User:' + query
463
                        };
464
                    },
465
                    preProcess: function (data) {
466
                        var results = data.query.prefixsearch.map(function (elem) {
467
                            return elem.title.split('/')[0].substr(elem.title.indexOf(':') + 1);
468
                        });
469
470
                        return results.filter(function (value, index, array) {
471
                            return array.indexOf(value) === index;
472
                        });
473
                    },
474
                })
475
            });
476
        }
477
    }
478
479
    /**
480
     * For any form submission, this disables the submit button and replaces its text with
481
     * a loading message and a counting timer.
482
     */
483
    function displayWaitingNoticeOnSubmission()
484
    {
485
        var $submitButtons = $("form, button.form-submit");
486
        $submitButtons.on("submit", function () {
487
            // Change the submit button text.
488
            var $submitButtons = $(this).find(":submit");
489
            $submitButtons.attr("disabled", true);
490
            $submitButtons.html($.i18n('loading') + " <span id='submitTimer'></span>");
491
            // Add the counter.
492
            var startTime =  Date.now();
493
            setInterval(function () {
494
                var elapsedSeconds = Math.round((Date.now() - startTime) / 1000);
495
                var minutes = Math.floor(elapsedSeconds/60);
496
                var seconds = (String)(elapsedSeconds - (minutes * 60)).padStart(2, "0");
497
                $("#submitTimer").text(minutes + ":" + seconds);
498
            }, 200);
499
            return true;
500
        });
501
    }
502
503
})();
504