statsd: Rename MediawikiStatsdDataFactory to IBufferingStatsdDataFactory
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / mw.rcfilters.Controller.js
1 ( function ( mw, $ ) {
2 /* eslint no-underscore-dangle: "off" */
3 /**
4 * Controller for the filters in Recent Changes
5 *
6 * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
7 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
8 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
9 */
10 mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel ) {
11 this.filtersModel = filtersModel;
12 this.changesListModel = changesListModel;
13 this.savedQueriesModel = savedQueriesModel;
14 this.requestCounter = 0;
15 this.baseFilterState = {};
16 this.uriProcessor = null;
17 this.initializing = false;
18 };
19
20 /* Initialization */
21 OO.initClass( mw.rcfilters.Controller );
22
23 /**
24 * Initialize the filter and parameter states
25 *
26 * @param {Array} filterStructure Filter definition and structure for the model
27 * @param {Object} [namespaceStructure] Namespace definition
28 * @param {Object} [tagList] Tag definition
29 */
30 mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList ) {
31 var parsedSavedQueries,
32 views = {},
33 items = [],
34 uri = new mw.Uri(),
35 $changesList = $( '.mw-changeslist' ).first().contents();
36
37 // Prepare views
38 if ( namespaceStructure ) {
39 items = [];
40 $.each( namespaceStructure, function ( namespaceID, label ) {
41 // Build and clean up the individual namespace items definition
42 items.push( {
43 name: namespaceID,
44 label: label || mw.msg( 'blanknamespace' ),
45 description: '',
46 identifiers: [
47 ( namespaceID < 0 || namespaceID % 2 === 0 ) ?
48 'subject' : 'talk'
49 ],
50 cssClass: 'mw-changeslist-ns-' + namespaceID
51 } );
52 } );
53
54 views.namespaces = {
55 title: mw.msg( 'namespaces' ),
56 trigger: ':',
57 groups: [ {
58 // Group definition (single group)
59 name: 'namespace', // parameter name is singular
60 type: 'string_options',
61 title: mw.msg( 'namespaces' ),
62 labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
63 separator: ';',
64 fullCoverage: true,
65 filters: items
66 } ]
67 };
68 }
69 if ( tagList ) {
70 views.tags = {
71 title: mw.msg( 'rcfilters-view-tags' ),
72 trigger: '#',
73 groups: [ {
74 // Group definition (single group)
75 name: 'tagfilter', // Parameter name
76 type: 'string_options',
77 title: 'rcfilters-view-tags', // Message key
78 labelPrefixKey: 'rcfilters-tag-prefix-tags',
79 separator: '|',
80 fullCoverage: false,
81 filters: tagList
82 } ]
83 };
84 }
85
86 // Initialize the model
87 this.filtersModel.initializeFilters( filterStructure, views );
88
89 this._buildBaseFilterState();
90
91 this.uriProcessor = new mw.rcfilters.UriProcessor(
92 this.filtersModel
93 );
94
95 try {
96 parsedSavedQueries = JSON.parse( mw.user.options.get( 'rcfilters-saved-queries' ) || '{}' );
97 } catch ( err ) {
98 parsedSavedQueries = {};
99 }
100
101 // The queries are saved in a minimized state, so we need
102 // to send over the base state so the saved queries model
103 // can normalize them per each query item
104 this.savedQueriesModel.initialize(
105 parsedSavedQueries,
106 this._getBaseFilterState()
107 );
108
109 // Check whether we need to load defaults.
110 // We do this by checking whether the current URI query
111 // contains any parameters recognized by the system.
112 // If it does, we load the given state.
113 // If it doesn't, we have no values at all, and we assume
114 // the user loads the base-page and we load defaults.
115 // Defaults should only be applied on load (if necessary)
116 // or on request
117 this.initializing = true;
118 if (
119 this.savedQueriesModel.getDefault() &&
120 !this.uriProcessor.doesQueryContainRecognizedParams( uri.query )
121 ) {
122 // We have defaults from a saved query.
123 // We will load them straight-forward (as if
124 // they were clicked in the menu) so we trigger
125 // a full ajax request and change of URL
126 this.applySavedQuery( this.savedQueriesModel.getDefault() );
127 } else {
128 // There are either recognized parameters in the URL
129 // or there are none, but there is also no default
130 // saved query (so defaults are from the backend)
131 // We want to update the state but not fetch results
132 // again
133 this.updateStateFromUrl( false );
134
135 // Update the changes list with the existing data
136 // so it gets processed
137 this.changesListModel.update(
138 $changesList.length ? $changesList : 'NO_RESULTS',
139 $( 'fieldset.rcoptions' ).first()
140 );
141 }
142
143 this.initializing = false;
144 this.switchView( 'default' );
145 };
146
147 /**
148 * Switch the view of the filters model
149 *
150 * @param {string} view Requested view
151 */
152 mw.rcfilters.Controller.prototype.switchView = function ( view ) {
153 this.filtersModel.switchView( view );
154 };
155
156 /**
157 * Reset to default filters
158 */
159 mw.rcfilters.Controller.prototype.resetToDefaults = function () {
160 this.uriProcessor.updateModelBasedOnQuery( this._getDefaultParams() );
161 this.updateChangesList();
162 };
163
164 /**
165 * Empty all selected filters
166 */
167 mw.rcfilters.Controller.prototype.emptyFilters = function () {
168 var highlightedFilterNames = this.filtersModel
169 .getHighlightedItems()
170 .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
171
172 this.filtersModel.emptyAllFilters();
173 this.filtersModel.clearAllHighlightColors();
174 // Check all filter interactions
175 this.filtersModel.reassessFilterInteractions();
176
177 this.updateChangesList();
178
179 if ( highlightedFilterNames ) {
180 this._trackHighlight( 'clearAll', highlightedFilterNames );
181 }
182 };
183
184 /**
185 * Update the selected state of a filter
186 *
187 * @param {string} filterName Filter name
188 * @param {boolean} [isSelected] Filter selected state
189 */
190 mw.rcfilters.Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
191 var filterItem = this.filtersModel.getItemByName( filterName );
192
193 if ( !filterItem ) {
194 // If no filter was found, break
195 return;
196 }
197
198 isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
199
200 if ( filterItem.isSelected() !== isSelected ) {
201 this.filtersModel.toggleFilterSelected( filterName, isSelected );
202
203 this.updateChangesList();
204
205 // Check filter interactions
206 this.filtersModel.reassessFilterInteractions( filterItem );
207 }
208 };
209
210 /**
211 * Clear both highlight and selection of a filter
212 *
213 * @param {string} filterName Name of the filter item
214 */
215 mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) {
216 var filterItem = this.filtersModel.getItemByName( filterName ),
217 isHighlighted = filterItem.isHighlighted();
218
219 if ( filterItem.isSelected() || isHighlighted ) {
220 this.filtersModel.clearHighlightColor( filterName );
221 this.filtersModel.toggleFilterSelected( filterName, false );
222 this.updateChangesList();
223 this.filtersModel.reassessFilterInteractions( filterItem );
224 }
225
226 if ( isHighlighted ) {
227 this._trackHighlight( 'clear', filterName );
228 }
229 };
230
231 /**
232 * Toggle the highlight feature on and off
233 */
234 mw.rcfilters.Controller.prototype.toggleHighlight = function () {
235 this.filtersModel.toggleHighlight();
236 this._updateURL();
237
238 if ( this.filtersModel.isHighlightEnabled() ) {
239 mw.hook( 'RcFilters.highlight.enable' ).fire();
240 }
241 };
242
243 /**
244 * Toggle the namespaces inverted feature on and off
245 */
246 mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () {
247 this.filtersModel.toggleInvertedNamespaces();
248 this.updateChangesList();
249 };
250
251 /**
252 * Set the highlight color for a filter item
253 *
254 * @param {string} filterName Name of the filter item
255 * @param {string} color Selected color
256 */
257 mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
258 this.filtersModel.setHighlightColor( filterName, color );
259 this._updateURL();
260 this._trackHighlight( 'set', { name: filterName, color: color } );
261 };
262
263 /**
264 * Clear highlight for a filter item
265 *
266 * @param {string} filterName Name of the filter item
267 */
268 mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
269 this.filtersModel.clearHighlightColor( filterName );
270 this._updateURL();
271 this._trackHighlight( 'clear', filterName );
272 };
273
274 /**
275 * Save the current model state as a saved query
276 *
277 * @param {string} [label] Label of the saved query
278 */
279 mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label ) {
280 var highlightedItems = {},
281 highlightEnabled = this.filtersModel.isHighlightEnabled();
282
283 // Prepare highlights
284 this.filtersModel.getHighlightedItems().forEach( function ( item ) {
285 highlightedItems[ item.getName() ] = highlightEnabled ?
286 item.getHighlightColor() : null;
287 } );
288 // These are filter states; highlight is stored as boolean
289 highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
290
291 // Add item
292 this.savedQueriesModel.addNewQuery(
293 label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
294 {
295 filters: this.filtersModel.getSelectedState(),
296 highlights: highlightedItems,
297 invert: this.filtersModel.areNamespacesInverted()
298 }
299 );
300
301 // Save item
302 this._saveSavedQueries();
303 };
304
305 /**
306 * Remove a saved query
307 *
308 * @param {string} queryID Query id
309 */
310 mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) {
311 this.savedQueriesModel.removeQuery( queryID );
312
313 this._saveSavedQueries();
314 };
315
316 /**
317 * Rename a saved query
318 *
319 * @param {string} queryID Query id
320 * @param {string} newLabel New label for the query
321 */
322 mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
323 var queryItem = this.savedQueriesModel.getItemByID( queryID );
324
325 if ( queryItem ) {
326 queryItem.updateLabel( newLabel );
327 }
328 this._saveSavedQueries();
329 };
330
331 /**
332 * Set a saved query as default
333 *
334 * @param {string} queryID Query Id. If null is given, default
335 * query is reset.
336 */
337 mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
338 this.savedQueriesModel.setDefault( queryID );
339 this._saveSavedQueries();
340 };
341
342 /**
343 * Load a saved query
344 *
345 * @param {string} queryID Query id
346 */
347 mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
348 var data, highlights,
349 queryItem = this.savedQueriesModel.getItemByID( queryID );
350
351 if ( queryItem ) {
352 data = queryItem.getData();
353 highlights = data.highlights;
354
355 // Backwards compatibility; initial version mispelled 'highlight' with 'highlights'
356 highlights.highlight = highlights.highlights || highlights.highlight;
357
358 // Update model state from filters
359 this.filtersModel.toggleFiltersSelected( data.filters );
360
361 // Update namespace inverted property
362 this.filtersModel.toggleInvertedNamespaces( !!Number( data.invert ) );
363
364 // Update highlight state
365 this.filtersModel.toggleHighlight( !!Number( highlights.highlight ) );
366 this.filtersModel.getItems().forEach( function ( filterItem ) {
367 var color = highlights[ filterItem.getName() ];
368 if ( color ) {
369 filterItem.setHighlightColor( color );
370 } else {
371 filterItem.clearHighlightColor();
372 }
373 } );
374
375 // Check all filter interactions
376 this.filtersModel.reassessFilterInteractions();
377
378 this.updateChangesList();
379 }
380 };
381
382 /**
383 * Check whether the current filter and highlight state exists
384 * in the saved queries model.
385 *
386 * @return {boolean} Query exists
387 */
388 mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
389 var highlightedItems = {};
390
391 // Prepare highlights of the current query
392 this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
393 highlightedItems[ item.getName() ] = item.getHighlightColor();
394 } );
395 highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
396
397 return this.savedQueriesModel.findMatchingQuery(
398 {
399 filters: this.filtersModel.getSelectedState(),
400 highlights: highlightedItems,
401 invert: this.filtersModel.areNamespacesInverted()
402 }
403 );
404 };
405
406 /**
407 * Get an object representing the base state of parameters
408 * and highlights.
409 *
410 * This is meant to make sure that the saved queries that are
411 * in memory are always the same structure as what we would get
412 * by calling the current model's "getSelectedState" and by checking
413 * highlight items.
414 *
415 * In cases where a user saved a query when the system had a certain
416 * set of filters, and then a filter was added to the system, we want
417 * to make sure that the stored queries can still be comparable to
418 * the current state, which means that we need the base state for
419 * two operations:
420 *
421 * - Saved queries are stored in "minimal" view (only changed filters
422 * are stored); When we initialize the system, we merge each minimal
423 * query with the base state (using 'getNormalizedFilters') so all
424 * saved queries have the exact same structure as what we would get
425 * by checking the getSelectedState of the filter.
426 * - When we save the queries, we minimize the object to only represent
427 * whatever has actually changed, rather than store the entire
428 * object. To check what actually is different so we can store it,
429 * we need to obtain a base state to compare against, this is
430 * what #_getMinimalFilterList does
431 */
432 mw.rcfilters.Controller.prototype._buildBaseFilterState = function () {
433 var defaultParams = this.filtersModel.getDefaultParams(),
434 highlightedItems = {};
435
436 // Prepare highlights
437 this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
438 highlightedItems[ item.getName() ] = null;
439 } );
440 highlightedItems.highlight = false;
441
442 this.baseFilterState = {
443 filters: this.filtersModel.getFiltersFromParameters( defaultParams ),
444 highlights: highlightedItems,
445 invert: false
446 };
447 };
448
449 /**
450 * Get an object representing the base filter state of both
451 * filters and highlights. The structure is similar to what we use
452 * to store each query in the saved queries object:
453 * {
454 * filters: {
455 * filterName: (bool)
456 * },
457 * highlights: {
458 * filterName: (string|null)
459 * }
460 * }
461 *
462 * @return {Object} Object representing the base state of
463 * parameters and highlights
464 */
465 mw.rcfilters.Controller.prototype._getBaseFilterState = function () {
466 return this.baseFilterState;
467 };
468
469 /**
470 * Get an object that holds only the parameters and highlights that have
471 * values different than the base default value.
472 *
473 * This is the reverse of the normalization we do initially on loading and
474 * initializing the saved queries model.
475 *
476 * @param {Object} valuesObject Object representing the state of both
477 * filters and highlights in its normalized version, to be minimized.
478 * @return {Object} Minimal filters and highlights list
479 */
480 mw.rcfilters.Controller.prototype._getMinimalFilterList = function ( valuesObject ) {
481 var result = { filters: {}, highlights: {} },
482 baseState = this._getBaseFilterState();
483
484 // XOR results
485 $.each( valuesObject.filters, function ( name, value ) {
486 if ( baseState.filters !== undefined && baseState.filters[ name ] !== value ) {
487 result.filters[ name ] = value;
488 }
489 } );
490
491 $.each( valuesObject.highlights, function ( name, value ) {
492 if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value ) {
493 result.highlights[ name ] = value;
494 }
495 } );
496
497 return result;
498 };
499
500 /**
501 * Save the current state of the saved queries model with all
502 * query item representation in the user settings.
503 */
504 mw.rcfilters.Controller.prototype._saveSavedQueries = function () {
505 var stringified,
506 state = this.savedQueriesModel.getState(),
507 controller = this;
508
509 // Minimize before save
510 $.each( state.queries, function ( queryID, info ) {
511 state.queries[ queryID ].data = controller._getMinimalFilterList( info.data );
512 } );
513
514 // Stringify state
515 stringified = JSON.stringify( state );
516
517 if ( stringified.length > 65535 ) {
518 // Sanity check, since the preference can only hold that.
519 return;
520 }
521
522 // Save the preference
523 new mw.Api().saveOption( 'rcfilters-saved-queries', stringified );
524 // Update the preference for this session
525 mw.user.options.set( 'rcfilters-saved-queries', stringified );
526 };
527
528 /**
529 * Synchronize the URL with the current state of the filters
530 * without adding an history entry.
531 */
532 mw.rcfilters.Controller.prototype.replaceUrl = function () {
533 mw.rcfilters.UriProcessor.static.replaceState( this._getUpdatedUri() );
534 };
535
536 /**
537 * Update filter state (selection and highlighting) based
538 * on current URL values.
539 *
540 * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
541 * list based on the updated model.
542 */
543 mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
544 fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
545
546 this.uriProcessor.updateModelBasedOnQuery( new mw.Uri().query );
547
548 // Only update and fetch new results if it is requested
549 if ( fetchChangesList ) {
550 this.updateChangesList();
551 }
552 };
553
554 /**
555 * Update the list of changes and notify the model
556 *
557 * @param {Object} [params] Extra parameters to add to the API call
558 */
559 mw.rcfilters.Controller.prototype.updateChangesList = function ( params ) {
560 this._updateURL( params );
561 this.changesListModel.invalidate();
562 this._fetchChangesList()
563 .then(
564 // Success
565 function ( pieces ) {
566 var $changesListContent = pieces.changes,
567 $fieldset = pieces.fieldset;
568 this.changesListModel.update( $changesListContent, $fieldset );
569 }.bind( this )
570 // Do nothing for failure
571 );
572 };
573
574 /**
575 * Get an object representing the default parameter state, whether
576 * it is from the model defaults or from the saved queries.
577 *
578 * @return {Object} Default parameters
579 */
580 mw.rcfilters.Controller.prototype._getDefaultParams = function () {
581 var data, queryHighlights,
582 savedParams = {},
583 savedHighlights = {},
584 defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
585
586 if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
587 defaultSavedQueryItem ) {
588
589 data = defaultSavedQueryItem.getData();
590
591 queryHighlights = data.highlights || {};
592 savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
593
594 // Translate highlights to parameters
595 savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
596 $.each( queryHighlights, function ( filterName, color ) {
597 if ( filterName !== 'highlights' ) {
598 savedHighlights[ filterName + '_color' ] = color;
599 }
600 } );
601
602 return $.extend( true, {}, savedParams, savedHighlights, { invert: data.invert } );
603 }
604
605 return $.extend(
606 { highlight: '0' },
607 this.filtersModel.getDefaultParams()
608 );
609 };
610
611 /**
612 * Get an object representing the default parameter state, whether
613 * it is from the model defaults or from the saved queries.
614 *
615 * @return {Object} Default parameters
616 */
617 mw.rcfilters.Controller.prototype._getDefaultParams = function () {
618 var data, queryHighlights,
619 savedParams = {},
620 savedHighlights = {},
621 defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
622
623 if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
624 defaultSavedQueryItem ) {
625
626 data = defaultSavedQueryItem.getData();
627
628 queryHighlights = data.highlights || {};
629 savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
630
631 // Translate highlights to parameters
632 savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
633 $.each( queryHighlights, function ( filterName, color ) {
634 if ( filterName !== 'highlights' ) {
635 savedHighlights[ filterName + '_color' ] = color;
636 }
637 } );
638
639 return $.extend( true, {}, savedParams, savedHighlights );
640 }
641
642 return this.filtersModel.getDefaultParams();
643 };
644
645 /**
646 * Update the URL of the page to reflect current filters
647 *
648 * This should not be called directly from outside the controller.
649 * If an action requires changing the URL, it should either use the
650 * highlighting actions below, or call #updateChangesList which does
651 * the uri corrections already.
652 *
653 * @param {Object} [params] Extra parameters to add to the API call
654 */
655 mw.rcfilters.Controller.prototype._updateURL = function ( params ) {
656 var currentUri = new mw.Uri(),
657 updatedUri = this._getUpdatedUri();
658
659 updatedUri.extend( params || {} );
660
661 if (
662 this.uriProcessor.getVersion( currentUri.query ) !== 2 ||
663 this.uriProcessor.isNewState( currentUri.query, updatedUri.query )
664 ) {
665 mw.rcfilters.UriProcessor.static.replaceState( updatedUri );
666 }
667 };
668
669 /**
670 * Get an updated mw.Uri object based on the model state
671 *
672 * @return {mw.Uri} Updated Uri
673 */
674 mw.rcfilters.Controller.prototype._getUpdatedUri = function () {
675 var uri = new mw.Uri();
676
677 // Minimize url
678 uri.query = this.uriProcessor.minimizeQuery(
679 $.extend(
680 true,
681 {},
682 // We want to retain unrecognized params
683 // The uri params from model will override
684 // any recognized value in the current uri
685 // query, retain unrecognized params, and
686 // the result will then be minimized
687 uri.query,
688 this.uriProcessor.getUriParametersFromModel(),
689 { urlversion: '2' }
690 )
691 );
692
693 return uri;
694 };
695
696 /**
697 * Fetch the list of changes from the server for the current filters
698 *
699 * @return {jQuery.Promise} Promise object that will resolve with the changes list
700 * or with a string denoting no results.
701 */
702 mw.rcfilters.Controller.prototype._fetchChangesList = function () {
703 var uri = this._getUpdatedUri(),
704 requestId = ++this.requestCounter,
705 latestRequest = function () {
706 return requestId === this.requestCounter;
707 }.bind( this );
708
709 return $.ajax( uri.toString(), { contentType: 'html' } )
710 .then(
711 // Success
712 function ( html ) {
713 var $parsed;
714 if ( !latestRequest() ) {
715 return $.Deferred().reject();
716 }
717
718 $parsed = $( $.parseHTML( html ) );
719
720 return {
721 // Changes list
722 changes: $parsed.find( '.mw-changeslist' ).first().contents(),
723 // Fieldset
724 fieldset: $parsed.find( 'fieldset.rcoptions' ).first()
725 };
726 },
727 // Failure
728 function ( responseObj ) {
729 var $parsed;
730
731 if ( !latestRequest() ) {
732 return $.Deferred().reject();
733 }
734
735 $parsed = $( $.parseHTML( responseObj.responseText ) );
736
737 // Force a resolve state to this promise
738 return $.Deferred().resolve( {
739 changes: 'NO_RESULTS',
740 fieldset: $parsed.find( 'fieldset.rcoptions' ).first()
741 } ).promise();
742 }
743 );
744 };
745
746 /**
747 * Track usage of highlight feature
748 *
749 * @param {string} action
750 * @param {array|object|string} filters
751 */
752 mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
753 filters = typeof filters === 'string' ? { name: filters } : filters;
754 filters = !Array.isArray( filters ) ? [ filters ] : filters;
755 mw.track(
756 'event.ChangesListHighlights',
757 {
758 action: action,
759 filters: filters,
760 userId: mw.user.getId()
761 }
762 );
763 };
764
765 }( mediaWiki, jQuery ) );