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