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