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