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