Completed
Push — master ( be0721...cced22 )
by Matthew
02:48
created

application.js ➔ ... ➔ $.done   B

Complexity

Conditions 4
Paths 3

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

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