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