Merge "RCFilters: Adjust server default variable names for limit/days"
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / mw.rcfilters.Controller.js
index 5ebec27..b489f4e 100644 (file)
@@ -2,7 +2,9 @@
        /* eslint no-underscore-dangle: "off" */
        /**
         * Controller for the filters in Recent Changes
+        * @class
         *
+        * @constructor
         * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
         * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
         * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
@@ -11,7 +13,7 @@
                this.filtersModel = filtersModel;
                this.changesListModel = changesListModel;
                this.savedQueriesModel = savedQueriesModel;
-               this.requestCounter = 0;
+               this.requestCounter = {};
                this.baseFilterState = {};
                this.uriProcessor = null;
                this.initializing = false;
         * @param {Object} [tagList] Tag definition
         */
        mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList ) {
-               var parsedSavedQueries,
+               var parsedSavedQueries, limitDefault,
+                       displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
+                       controller = this,
                        views = {},
                        items = [],
                        uri = new mw.Uri(),
-                       $changesList = $( '.mw-changeslist' ).first().contents(),
-                       createFilterDataFromNumber = function ( num, convertedNumForLabel ) {
-                               return {
-                                       name: String( num ),
-                                       label: mw.language.convertNumber( convertedNumForLabel )
-                               };
-                       };
+                       $changesList = $( '.mw-changeslist' ).first().contents();
 
                // Prepare views
                if ( namespaceStructure ) {
                        };
                }
 
+               // Convert the default from the old preference
+               // since the limit preference actually affects more
+               // than just the RecentChanges page
+               limitDefault = Number( mw.user.options.get( 'rclimit', '50' ) );
+
                // Add parameter range operations
                views.range = {
                        groups: [
                                        hidden: true,
                                        allowArbitrary: true,
                                        validate: $.isNumeric,
+                                       range: {
+                                               min: 0, // The server normalizes negative numbers to 0 results
+                                               max: 1000
+                                       },
                                        sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
-                                       'default': '50',
-                                       filters: [ 50, 100, 250, 500 ].map( function ( num ) {
-                                               return createFilterDataFromNumber( num, num );
+                                       'default': String( limitDefault ),
+                                       // Temporarily making this not sticky until we resolve the problem
+                                       // with the misleading preference. Note that if this is to be permanent
+                                       // we should remove all sticky behavior methods completely
+                                       // See T172156
+                                       // isSticky: true,
+                                       filters: displayConfig.limitArray.map( function ( num ) {
+                                               return controller._createFilterDataFromNumber( num, num );
                                        } )
                                },
                                {
                                        hidden: true,
                                        allowArbitrary: true,
                                        validate: $.isNumeric,
+                                       range: {
+                                               min: 0,
+                                               max: displayConfig.maxDays
+                                       },
                                        sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
-                                       'default': '7',
+                                       numToLabelFunc: function ( i ) {
+                                               return Number( i ) < 1 ?
+                                                       ( Number( i ) * 24 ).toFixed( 2 ) :
+                                                       Number( i );
+                                       },
+                                       'default': mw.user.options.get( 'rcdays', '30' ),
+                                       // Temporarily making this not sticky while limit is not sticky, see above
+                                       // isSticky: true,
                                        filters: [
                                                // Hours (1, 2, 6, 12)
-                                               0.04166, 0.0833, 0.25, 0.5,
-                                               // Days
-                                               1, 3, 7, 14, 30
-                                       ].map( function ( num ) {
-                                               return createFilterDataFromNumber(
-                                                       num,
-                                                       // Convert fractions of days to number of hours for the labels
-                                                       num < 1 ? Math.round( num * 24 ) : num
-                                               );
-                                       } )
+                                               0.04166, 0.0833, 0.25, 0.5
+                                       // Days
+                                       ].concat( displayConfig.daysArray )
+                                               .map( function ( num ) {
+                                                       return controller._createFilterDataFromNumber(
+                                                               num,
+                                                               // Convert fractions of days to number of hours for the labels
+                                                               num < 1 ? Math.round( num * 24 ) : num
+                                                       );
+                                               } )
+                               }
+                       ]
+               };
+
+               views.display = {
+                       groups: [
+                               {
+                                       name: 'display',
+                                       type: 'boolean',
+                                       title: '', // Because it's a hidden group, this title actually appears nowhere
+                                       hidden: true,
+                                       isSticky: true,
+                                       filters: [
+                                               {
+                                                       name: 'enhanced',
+                                                       'default': String( mw.user.options.get( 'usenewrc', 0 ) )
+                                               }
+                                       ]
                                }
                        ]
                };
 
-               // Before we do anything, we need to see if we require another item in the
+               // Before we do anything, we need to see if we require additional items in the
                // groups that have 'AllowArbitrary'. For the moment, those are only single_option
                // groups; if we ever expand it, this might need further generalization:
                $.each( views, function ( viewName, viewData ) {
                        viewData.groups.forEach( function ( groupData ) {
-                               // This is only true for single_option and string_options
-                               // We assume these are the only groups that will allow for
-                               // arbitrary, since it doesn't make any sense for the other
-                               // groups.
-                               var uriValue = uri.query[ groupData.name ];
-
-                               if (
-                                       // If the group allows for arbitrary data
-                                       groupData.allowArbitrary &&
-                                       // and it is single_option (or string_options, but we
-                                       // don't have cases of those yet, nor do we plan to)
-                                       groupData.type === 'single_option' &&
-                                       // and if there is a valid value in the URI already
-                                       uri.query[ groupData.name ] !== undefined &&
-                                       // and, if there is a validate method and it passes on
-                                       // the data
-                                       ( !groupData.validate || groupData.validate( uri.query[ groupData.name ] ) ) &&
-                                       // but if that value isn't already in the definition
-                                       groupData.filters
-                                               .map( function ( filterData ) {
-                                                       return filterData.name;
-                                               } )
-                                               .indexOf( uri.query[ groupData.name ] ) === -1
-                               ) {
-                                       // Add the filter information
-                                       if ( groupData.name === 'days' ) {
-                                               // Specific fix for hours/days which go by the same param
-                                               groupData.filters.push( createFilterDataFromNumber(
-                                                       uriValue,
-                                                       // In this case we don't want to round because it can be arbitrary
-                                                       // weird numbers but we want to round to 2 decimal digits
-                                                       Number( uriValue ) < 1 ?
-                                                               ( Number( uriValue ) * 24 ).toFixed( 2 ) :
-                                                               Number( uriValue )
-                                               ) );
-                                       } else {
-                                               groupData.filters.push( createFilterDataFromNumber( uriValue, uriValue ) );
+                               var extraValues = [];
+                               if ( groupData.allowArbitrary ) {
+                                       // If the value in the URI isn't in the group, add it
+                                       if ( uri.query[ groupData.name ] !== undefined ) {
+                                               extraValues.push( uri.query[ groupData.name ] );
                                        }
-
-                                       // If there's a sort function set up, re-sort the values
-                                       if ( groupData.sortFunc ) {
-                                               groupData.filters.sort( groupData.sortFunc );
+                                       // If the default value isn't in the group, add it
+                                       if ( groupData.default !== undefined ) {
+                                               extraValues.push( String( groupData.default ) );
                                        }
+                                       controller.addNumberValuesToGroup( groupData, extraValues );
                                }
                        } );
                } );
                // can normalize them per each query item
                this.savedQueriesModel.initialize(
                        parsedSavedQueries,
-                       this._getBaseFilterState()
+                       this._getBaseFilterState(),
+                       // This is for backwards compatibility - delete all sticky filter states
+                       Object.keys( this.filtersModel.getStickyFiltersState() )
                );
 
                // Check whether we need to load defaults.
 
                this.initializing = false;
                this.switchView( 'default' );
+
+               this._scheduleLiveUpdate();
+       };
+
+       /**
+        * Create filter data from a number, for the filters that are numerical value
+        *
+        * @param {Number} num Number
+        * @param {Number} numForDisplay Number for the label
+        * @return {Object} Filter data
+        */
+       mw.rcfilters.Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
+               return {
+                       name: String( num ),
+                       label: mw.language.convertNumber( numForDisplay )
+               };
+       };
+
+       /**
+        * Add an arbitrary values to groups that allow arbitrary values
+        *
+        * @param {Object} groupData Group data
+        * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
+        */
+       mw.rcfilters.Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
+               var controller = this,
+                       normalizeWithinRange = function ( range, val ) {
+                               if ( val < range.min ) {
+                                       return range.min; // Min
+                               } else if ( val >= range.max ) {
+                                       return range.max; // Max
+                               }
+                               return val;
+                       };
+
+               arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
+
+               // Normalize the arbitrary values and the default value for a range
+               if ( groupData.range ) {
+                       arbitraryValues = arbitraryValues.map( function ( val ) {
+                               return normalizeWithinRange( groupData.range, val );
+                       } );
+
+                       // Normalize the default, since that's user defined
+                       if ( groupData.default !== undefined ) {
+                               groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
+                       }
+               }
+
+               // This is only true for single_option group
+               // We assume these are the only groups that will allow for
+               // arbitrary, since it doesn't make any sense for the other
+               // groups.
+               arbitraryValues.forEach( function ( val ) {
+                       if (
+                               // If the group allows for arbitrary data
+                               groupData.allowArbitrary &&
+                               // and it is single_option (or string_options, but we
+                               // don't have cases of those yet, nor do we plan to)
+                               groupData.type === 'single_option' &&
+                               // and, if there is a validate method and it passes on
+                               // the data
+                               ( !groupData.validate || groupData.validate( val ) ) &&
+                               // but if that value isn't already in the definition
+                               groupData.filters
+                                       .map( function ( filterData ) {
+                                               return String( filterData.name );
+                                       } )
+                                       .indexOf( String( val ) ) === -1
+                       ) {
+                               // Add the filter information
+                               groupData.filters.push( controller._createFilterDataFromNumber(
+                                       val,
+                                       groupData.numToLabelFunc ?
+                                               groupData.numToLabelFunc( val ) :
+                                               val
+                               ) );
+
+                               // If there's a sort function set up, re-sort the values
+                               if ( groupData.sortFunc ) {
+                                       groupData.filters.sort( groupData.sortFunc );
+                               }
+                       }
+               } );
        };
 
        /**
         */
        mw.rcfilters.Controller.prototype.resetToDefaults = function () {
                this.uriProcessor.updateModelBasedOnQuery( this._getDefaultParams() );
+
                this.updateChangesList();
        };
 
         */
        mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () {
                this.filtersModel.toggleInvertedNamespaces();
-               this.updateChangesList();
+
+               if (
+                       this.filtersModel.getFiltersByView( 'namespaces' )
+                               .filter( function ( filterItem ) {
+                                       return filterItem.isSelected();
+                               } )
+                               .length
+               ) {
+                       // Only re-fetch results if there are namespace items that are actually selected
+                       this.updateChangesList();
+               }
        };
 
        /**
         * @param {boolean} enable True to enable, false to disable
         */
        mw.rcfilters.Controller.prototype.toggleLiveUpdate = function ( enable ) {
-               if ( enable && !this.liveUpdateTimeout ) {
-                       this._scheduleLiveUpdate();
-               } else if ( !enable && this.liveUpdateTimeout ) {
-                       clearTimeout( this.liveUpdateTimeout );
-                       this.liveUpdateTimeout = null;
+               this.changesListModel.toggleLiveUpdate( enable );
+               if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
+                       this.showNewChanges();
                }
        };
 
         * @private
         */
        mw.rcfilters.Controller.prototype._scheduleLiveUpdate = function () {
-               this.liveUpdateTimeout = setTimeout( this._doLiveUpdate.bind( this ), 3000 );
+               setTimeout( this._doLiveUpdate.bind( this ), 3000 );
        };
 
        /**
         * @private
         */
        mw.rcfilters.Controller.prototype._doLiveUpdate = function () {
-               var controller = this;
-               this.updateChangesList( {}, true )
-                       .always( function () {
-                               if ( controller.liveUpdateTimeout ) {
-                                       // Live update was not disabled in the meantime
-                                       controller._scheduleLiveUpdate();
+               if ( !this._shouldCheckForNewChanges() ) {
+                       // skip this turn and check back later
+                       this._scheduleLiveUpdate();
+                       return;
+               }
+
+               this._checkForNewChanges()
+                       .then( function ( data ) {
+                               if ( !this._shouldCheckForNewChanges() ) {
+                                       // by the time the response is received,
+                                       // it may not be appropriate anymore
+                                       return;
                                }
-                       } );
+
+                               if ( data.changes !== 'NO_RESULTS' ) {
+                                       if ( this.changesListModel.getLiveUpdate() ) {
+                                               return this.updateChangesList( false, null, true, false );
+                                       } else {
+                                               this.changesListModel.setNewChangesExist( true );
+                                       }
+                               }
+                       }.bind( this ) )
+                       .always( this._scheduleLiveUpdate.bind( this ) );
+       };
+
+       /**
+        * @return {boolean} It's appropriate to check for new changes now
+        * @private
+        */
+       mw.rcfilters.Controller.prototype._shouldCheckForNewChanges = function () {
+               var liveUpdateFeatureFlag = mw.config.get( 'wgStructuredChangeFiltersEnableLiveUpdate' ) ||
+                       new mw.Uri().query.liveupdate;
+
+               return !document.hidden &&
+                       !this.changesListModel.getNewChangesExist() &&
+                       !this.updatingChangesList &&
+                       liveUpdateFeatureFlag;
+       };
+
+       /**
+        * Check if new changes, newer than those currently shown, are available
+        *
+        * @return {jQuery.Promise} Promise object that resolves after trying
+        * to fetch 1 change newer than the last known 'from' parameter value
+        *
+        * @private
+        */
+       mw.rcfilters.Controller.prototype._checkForNewChanges = function () {
+               return this._fetchChangesList(
+                       'liveUpdate',
+                       {
+                               limit: 1,
+                               from: this.changesListModel.getNextFrom()
+                       }
+               );
+       };
+
+       /**
+        * Show the new changes
+        *
+        * @return {jQuery.Promise} Promise object that resolves after
+        * fetching and showing the new changes
+        */
+       mw.rcfilters.Controller.prototype.showNewChanges = function () {
+               return this.updateChangesList( false, null, true, true );
        };
 
        /**
         * Save the current model state as a saved query
         *
         * @param {string} [label] Label of the saved query
+        * @param {boolean} [setAsDefault=false] This query should be set as the default
         */
-       mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label ) {
-               var highlightedItems = {},
-                       highlightEnabled = this.filtersModel.isHighlightEnabled();
+       mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
+               var queryID,
+                       highlightedItems = {},
+                       highlightEnabled = this.filtersModel.isHighlightEnabled(),
+                       selectedState = this.filtersModel.getSelectedState();
 
                // Prepare highlights
                this.filtersModel.getHighlightedItems().forEach( function ( item ) {
                // These are filter states; highlight is stored as boolean
                highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
 
+               // Delete all sticky filters
+               this._deleteStickyValuesFromFilterState( selectedState );
+
                // Add item
-               this.savedQueriesModel.addNewQuery(
+               queryID = this.savedQueriesModel.addNewQuery(
                        label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
                        {
-                               filters: this.filtersModel.getSelectedState(),
+                               filters: selectedState,
                                highlights: highlightedItems,
                                invert: this.filtersModel.areNamespacesInverted()
                        }
                );
 
+               if ( setAsDefault ) {
+                       this.savedQueriesModel.setDefault( queryID );
+               }
+
                // Save item
                this._saveSavedQueries();
        };
         */
        mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
                var data, highlights,
-                       queryItem = this.savedQueriesModel.getItemByID( queryID );
+                       queryItem = this.savedQueriesModel.getItemByID( queryID ),
+                       currentMatchingQuery = this.findQueryMatchingCurrentState();
 
-               if ( queryItem ) {
+               if (
+                       queryItem &&
+                       (
+                               // If there's already a query, don't reload it
+                               // if it's the same as the one that already exists
+                               !currentMatchingQuery ||
+                               currentMatchingQuery.getID() !== queryItem.getID()
+                       )
+               ) {
                        data = queryItem.getData();
                        highlights = data.highlights;
 
                        highlights.highlight = highlights.highlights || highlights.highlight;
 
                        // Update model state from filters
-                       this.filtersModel.toggleFiltersSelected( data.filters );
+                       this.filtersModel.toggleFiltersSelected(
+                               // Merge filters with sticky values
+                               $.extend( true, {}, data.filters, this.filtersModel.getStickyFiltersState() )
+                       );
 
                        // Update namespace inverted property
                        this.filtersModel.toggleInvertedNamespaces( !!Number( data.invert ) );
         * @return {boolean} Query exists
         */
        mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
-               var highlightedItems = {};
+               var highlightedItems = {},
+                       selectedState = this.filtersModel.getSelectedState();
 
                // Prepare highlights of the current query
                this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
                } );
                highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
 
+               // Remove sticky filters
+               this._deleteStickyValuesFromFilterState( selectedState );
+
                return this.savedQueriesModel.findMatchingQuery(
                        {
-                               filters: this.filtersModel.getSelectedState(),
+                               filters: selectedState,
                                highlights: highlightedItems,
                                invert: this.filtersModel.areNamespacesInverted()
                        }
                );
        };
 
+       /**
+        * Delete sticky filters from given object
+        *
+        * @param {Object} filterState Filter state
+        */
+       mw.rcfilters.Controller.prototype._deleteStickyValuesFromFilterState = function ( filterState ) {
+               // Remove sticky filters
+               $.each( this.filtersModel.getStickyFiltersState(), function ( filterName ) {
+                       delete filterState[ filterName ];
+               } );
+       };
+
        /**
         * Get an object representing the base state of parameters
         * and highlights.
                mw.user.options.set( 'rcfilters-saved-queries', stringified );
        };
 
+       /**
+        * Update sticky preferences with current model state
+        */
+       mw.rcfilters.Controller.prototype.updateStickyPreferences = function () {
+               // Update default sticky values with selected, whether they came from
+               // the initial defaults or from the URL value that is being normalized
+               this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).getSelectedItems()[ 0 ].getParamName() );
+               this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).getSelectedItems()[ 0 ].getParamName() );
+
+               // TODO: Make these automatic by having the model go over sticky
+               // items and update their default values automatically
+       };
+
+       /**
+        * Update the limit default value
+        *
+        * param {number} newValue New value
+        */
+       mw.rcfilters.Controller.prototype.updateLimitDefault = function ( /* newValue */ ) {
+               // HACK: Temporarily remove this from being sticky
+               // See T172156
+
+               /*
+               if ( !$.isNumeric( newValue ) ) {
+                       return;
+               }
+
+               newValue = Number( newValue );
+
+               if ( mw.user.options.get( 'rcfilters-rclimit' ) !== newValue ) {
+                       // Save the preference
+                       new mw.Api().saveOption( 'rcfilters-rclimit', newValue );
+                       // Update the preference for this session
+                       mw.user.options.set( 'rcfilters-rclimit', newValue );
+               }
+               */
+               return;
+       };
+
+       /**
+        * Update the days default value
+        *
+        * param {number} newValue New value
+        */
+       mw.rcfilters.Controller.prototype.updateDaysDefault = function ( /* newValue */ ) {
+               // HACK: Temporarily remove this from being sticky
+               // See T172156
+
+               /*
+               if ( !$.isNumeric( newValue ) ) {
+                       return;
+               }
+
+               newValue = Number( newValue );
+
+               if ( mw.user.options.get( 'rcdays' ) !== newValue ) {
+                       // Save the preference
+                       new mw.Api().saveOption( 'rcdays', newValue );
+                       // Update the preference for this session
+                       mw.user.options.set( 'rcdays', newValue );
+               }
+               */
+               return;
+       };
+
+       /**
+        * Update the group by page default value
+        *
+        * @param {number} newValue New value
+        */
+       mw.rcfilters.Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
+               if ( !$.isNumeric( newValue ) ) {
+                       return;
+               }
+
+               newValue = Number( newValue );
+
+               if ( mw.user.options.get( 'usenewrc' ) !== newValue ) {
+                       // Save the preference
+                       new mw.Api().saveOption( 'usenewrc', newValue );
+                       // Update the preference for this session
+                       mw.user.options.set( 'usenewrc', newValue );
+               }
+       };
+
        /**
         * Synchronize the URL with the current state of the filters
         * without adding an history entry.
 
                this.uriProcessor.updateModelBasedOnQuery( new mw.Uri().query );
 
+               // Update the sticky preferences, in case we received a value
+               // from the URL
+               this.updateStickyPreferences();
+
                // Only update and fetch new results if it is requested
                if ( fetchChangesList ) {
                        this.updateChangesList();
        /**
         * Update the list of changes and notify the model
         *
+        * @param {boolean} [updateUrl=true] Whether the URL should be updated with the current state of the filters
         * @param {Object} [params] Extra parameters to add to the API call
-        * @param {boolean} [isLiveUpdate] Don't update the URL or invalidate the changes list
+        * @param {boolean} [isLiveUpdate=false] The purpose of this update is to show new results for the same filters
+        * @param {boolean} [invalidateCurrentChanges=true] Invalidate current changes by default (show spinner)
         * @return {jQuery.Promise} Promise that is resolved when the update is complete
         */
-       mw.rcfilters.Controller.prototype.updateChangesList = function ( params, isLiveUpdate ) {
-               if ( !isLiveUpdate ) {
+       mw.rcfilters.Controller.prototype.updateChangesList = function ( updateUrl, params, isLiveUpdate, invalidateCurrentChanges ) {
+               updateUrl = updateUrl === undefined ? true : updateUrl;
+               invalidateCurrentChanges = invalidateCurrentChanges === undefined ? true : invalidateCurrentChanges;
+               if ( updateUrl ) {
                        this._updateURL( params );
+               }
+               if ( invalidateCurrentChanges ) {
                        this.changesListModel.invalidate();
                }
+               this.changesListModel.setNewChangesExist( false );
+               this.updatingChangesList = true;
                return this._fetchChangesList()
                        .then(
                                // Success
                                function ( pieces ) {
                                        var $changesListContent = pieces.changes,
                                                $fieldset = pieces.fieldset;
-                                       this.changesListModel.update( $changesListContent, $fieldset );
+                                       this.changesListModel.update( $changesListContent, $fieldset, false, isLiveUpdate );
                                }.bind( this )
                                // Do nothing for failure
-                       );
-       };
-
-       /**
-        * Get an object representing the default parameter state, whether
-        * it is from the model defaults or from the saved queries.
-        *
-        * @return {Object} Default parameters
-        */
-       mw.rcfilters.Controller.prototype._getDefaultParams = function () {
-               var data, queryHighlights,
-                       savedParams = {},
-                       savedHighlights = {},
-                       defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
-
-               if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
-                       defaultSavedQueryItem ) {
-
-                       data = defaultSavedQueryItem.getData();
-
-                       queryHighlights = data.highlights || {};
-                       savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
-
-                       // Translate highlights to parameters
-                       savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
-                       $.each( queryHighlights, function ( filterName, color ) {
-                               if ( filterName !== 'highlights' ) {
-                                       savedHighlights[ filterName + '_color' ] = color;
-                               }
-                       } );
-
-                       return $.extend( true, {}, savedParams, savedHighlights, { invert: data.invert } );
-               }
-
-               return $.extend(
-                       { highlight: '0' },
-                       this.filtersModel.getDefaultParams()
-               );
+                       )
+                       .always( function () {
+                               this.updatingChangesList = false;
+                       }.bind( this ) );
        };
 
        /**
                        savedHighlights = {},
                        defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
 
-               if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
-                       defaultSavedQueryItem ) {
-
+               if ( defaultSavedQueryItem ) {
                        data = defaultSavedQueryItem.getData();
 
                        queryHighlights = data.highlights || {};
-                       savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
+                       savedParams = this.filtersModel.getParametersFromFilters(
+                               // Merge filters with sticky values
+                               $.extend( true, {}, data.filters, this.filtersModel.getStickyFiltersState() )
+                       );
 
                        // Translate highlights to parameters
                        savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
        /**
         * Fetch the list of changes from the server for the current filters
         *
+        * @param {string} [counterId='updateChangesList'] Id for this request. To allow concurrent requests
+        *  not to invalidate each other.
+        * @param {Object} [params={}] Parameters to add to the query
+        *
         * @return {jQuery.Promise} Promise object that will resolve with the changes list
         *  or with a string denoting no results.
         */
-       mw.rcfilters.Controller.prototype._fetchChangesList = function () {
+       mw.rcfilters.Controller.prototype._fetchChangesList = function ( counterId, params ) {
                var uri = this._getUpdatedUri(),
-                       requestId = ++this.requestCounter,
-                       latestRequest = function () {
-                               return requestId === this.requestCounter;
-                       }.bind( this );
+                       stickyParams = this.filtersModel.getStickyParams(),
+                       requestId,
+                       latestRequest;
+
+               counterId = counterId || 'updateChangesList';
+               params = params || {};
+
+               uri.extend( params );
+
+               this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
+               requestId = ++this.requestCounter[ counterId ];
+               latestRequest = function () {
+                       return requestId === this.requestCounter[ counterId ];
+               }.bind( this );
+
+               // Sticky parameters override the URL params
+               // this is to make sure that whether we represent
+               // the sticky params in the URL or not (they may
+               // be normalized out) the sticky parameters are
+               // always being sent to the server with their
+               // current/default values
+               uri.extend( stickyParams );
 
                return $.ajax( uri.toString(), { contentType: 'html' } )
                        .then(
         * Track usage of highlight feature
         *
         * @param {string} action
-        * @param {array|object|string} filters
+        * @param {Array|Object|string} filters
         */
        mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
                filters = typeof filters === 'string' ? { name: filters } : filters;