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